diff --git a/cmd/auth.go b/cmd/auth.go index ee6aeee4..1a03c4a2 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -24,6 +24,8 @@ func authCmd() *cobra.Command { cmd.AddCommand(loginCmd()) cmd.AddCommand(infoCmd()) cmd.AddCommand(revokeCmd()) + cmd.AddCommand(listAccountsCmd()) + cmd.AddCommand(switchAccountCmd()) return cmd } @@ -167,3 +169,70 @@ func revokeCmd() *cobra.Command { }, } } + +func listAccountsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List saved App Store accounts", + RunE: func(cmd *cobra.Command, args []string) error { + data, err := dependencies.AppStore.AccountsInfo() + if err != nil { + return err + } + + for _, acc := range data.Accounts { + dependencies.Logger.Log(). + Str("name", acc.Name). + Str("email", acc.Email) + } + return nil + }, + } + + return cmd +} + +// IF provider with -e --email, then switch to that account +// if not provided, list accounts and prompt user to select one +func switchAccountCmd() *cobra.Command { + var email string + cmd := &cobra.Command{ + Use: "switch", + Short: "Switch to a different App Store account", + RunE: func(cmd *cobra.Command, args []string) error { + if email == "" { + accounts, err := dependencies.AppStore.AccountsInfo() + if err != nil { + return errors.New("no saved accounts find, please login first") + } + if len(accounts.Accounts) == 0 { + return errors.New("no saved accounts find, please login first") + } + + for i, acc := range accounts.Accounts { + fmt.Printf("[%d] %s <%s>\n", i+1, acc.Name, acc.Email) + } + + fmt.Print("Select an account by number: ") + var selection int + _, err = fmt.Scanf("%d", &selection) + if err != nil { + return fmt.Errorf("failed to read selection: %w", err) + } + if selection < 1 || selection > len(accounts.Accounts) { + return fmt.Errorf("invalid selection") + } + email = accounts.Accounts[selection-1].Email + fmt.Printf("Switching to account: %s\n", email) + _, err = dependencies.AppStore.SwitchAccount(email) + return err + } + _, err := dependencies.AppStore.SwitchAccount(email) + return err + }, + } + + cmd.Flags().StringVarP(&email, "email", "e", "", "email address for the Apple ID") + + return cmd +} diff --git a/pkg/appstore/account.go b/pkg/appstore/account.go index 310ba090..2f72d262 100644 --- a/pkg/appstore/account.go +++ b/pkg/appstore/account.go @@ -8,3 +8,8 @@ type Account struct { StoreFront string `json:"storeFront,omitempty"` Password string `json:"password,omitempty"` } + +type AccountStorage struct { + Accounts []Account `json:"accounts,omitempty"` + Current string `json:"current,omitempty"` +} diff --git a/pkg/appstore/appstore.go b/pkg/appstore/appstore.go index f6b15f48..8fc40855 100644 --- a/pkg/appstore/appstore.go +++ b/pkg/appstore/appstore.go @@ -12,6 +12,10 @@ type AppStore interface { Login(input LoginInput) (LoginOutput, error) // AccountInfo returns the information of the authenticated account. AccountInfo() (AccountInfoOutput, error) + // AccountsInfo returns the information of all saved accounts. + AccountsInfo() (AccountsInfoOutput, error) + // SwitchAccount switches to the specified account. + SwitchAccount(email string) (Account, error) // Revoke revokes the active credentials. Revoke() error // Lookup looks apps up based on the specified bundle identifier. diff --git a/pkg/appstore/appstore_account_info.go b/pkg/appstore/appstore_account_info.go index 90ec0791..bc53402e 100644 --- a/pkg/appstore/appstore_account_info.go +++ b/pkg/appstore/appstore_account_info.go @@ -9,14 +9,35 @@ type AccountInfoOutput struct { Account Account } +type AccountsInfoOutput struct { + Accounts []Account + Current string +} + +// Try read as Multiple Accounts storage +// otherwise as single account func (t *appstore) AccountInfo() (AccountInfoOutput, error) { - data, err := t.keychain.Get("account") + data, err := t.keychain.Get(AccountKey) if err != nil { return AccountInfoOutput{}, fmt.Errorf("failed to get account: %w", err) } var acc Account + var accounts AccountStorage + + err = json.Unmarshal(data, &accounts) + if err == nil && len(accounts.Accounts) > 0 { + // Return current account + for _, a := range accounts.Accounts { + if a.Email == accounts.Current { + return AccountInfoOutput{ + Account: a, + }, nil + } + } + } + // Fallback to single account err = json.Unmarshal(data, &acc) if err != nil { return AccountInfoOutput{}, fmt.Errorf("failed to unmarshal json: %w", err) @@ -26,3 +47,101 @@ func (t *appstore) AccountInfo() (AccountInfoOutput, error) { Account: acc, }, nil } + +func (t *appstore) AccountsInfo() (AccountsInfoOutput, error) { + data, err := t.keychain.Get(AccountKey) + if err != nil { + return AccountsInfoOutput{}, fmt.Errorf("failed to get account storage: %w", err) + } + + var storage AccountStorage + + err = json.Unmarshal(data, &storage) + if err != nil { + return AccountsInfoOutput{}, fmt.Errorf("failed to unmarshal json: %w", err) + } + + return AccountsInfoOutput(storage), nil +} + +// read from keychain and save to storage +// if is v2 data, just save it +// if is old data, convert and save it +func (t *appstore) saveAccount(acc Account) (Account, error) { + + var accountStorage AccountStorage + var accInKeychain Account + + rootData, err := t.keychain.Get(AccountKey) + if err != nil { + // Ignore error if account does not exist yet + return Account{}, err + } + err = json.Unmarshal(rootData, &accountStorage) + + if err != nil { + err = json.Unmarshal(rootData, &accInKeychain) + if err == nil { + accountStorage = AccountStorage{ + Accounts: []Account{accInKeychain}, + Current: accInKeychain.Email, + } + } + } + + // handle deduplicate accounts + var found bool + for _, a := range accountStorage.Accounts { + if a.Email == acc.Email { + found = true + break + } + } + if !found { + accountStorage.Accounts = append(accountStorage.Accounts, acc) + } + accountStorage.Current = acc.Email + + rootData, err = json.Marshal(accountStorage) + if err != nil { + return Account{}, fmt.Errorf("failed to marshal json: %w", err) + } + err = t.keychain.Set(AccountKey, rootData) + if err != nil { + return Account{}, fmt.Errorf("failed to save account storage in keychain: %w", err) + } + return acc, nil +} + +func (t *appstore) SwitchAccount(email string) (Account, error) { + accountStorage, err := t.AccountsInfo() + if err != nil { + return Account{}, fmt.Errorf("failed to get accounts info: %w", err) + } + + var found bool + var res Account + for _, acc := range accountStorage.Accounts { + if acc.Email == email { + found = true + res = acc + break + } + } + if !found { + return Account{}, fmt.Errorf("account with email %s not found", email) + } + + accountStorage.Current = email + + rootData, err := json.Marshal(accountStorage) + if err != nil { + return Account{}, fmt.Errorf("failed to marshal json: %w", err) + } + err = t.keychain.Set(AccountKey, rootData) + if err != nil { + return Account{}, fmt.Errorf("failed to save account storage in keychain: %w", err) + } + + return res, nil +} diff --git a/pkg/appstore/appstore_account_info_test.go b/pkg/appstore/appstore_account_info_test.go index 14a0b926..c4c78941 100644 --- a/pkg/appstore/appstore_account_info_test.go +++ b/pkg/appstore/appstore_account_info_test.go @@ -1,8 +1,8 @@ package appstore import ( + "encoding/json" "errors" - "fmt" "github.com/majd/ipatool/v2/pkg/keychain" . "github.com/onsi/ginkgo/v2" @@ -17,6 +17,11 @@ var _ = Describe("AppStore (AccountInfo)", func() { mockKeychain *keychain.MockKeychain ) + var ( + testEmail = "test-email" + testName = "test-name" + ) + BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeychain = keychain.NewMockKeychain(ctrl) @@ -30,15 +35,17 @@ var _ = Describe("AppStore (AccountInfo)", func() { }) When("keychain returns valid data", func() { - const ( - testEmail = "test-email" - testName = "test-name" - ) BeforeEach(func() { + var defaultAccount = Account{ + Email: testEmail, + Name: testName, + } + var expectedResult, _ = json.Marshal(defaultAccount) mockKeychain.EXPECT(). - Get("account"). - Return([]byte(fmt.Sprintf("{\"email\": \"%s\", \"name\": \"%s\"}", testEmail, testName)), nil) + Get(AccountKey). + Return(expectedResult, nil). + AnyTimes() }) It("returns output", func() { @@ -49,11 +56,31 @@ var _ = Describe("AppStore (AccountInfo)", func() { }) }) + When("keychain returns new version valid data", func() { + BeforeEach(func() { + var defaultAccount = Account{ + Email: testEmail, + Name: testName, + } + var accountStorage = AccountStorage{ + Current: testEmail, + Accounts: []Account{defaultAccount}, + } + var expectedResult, _ = json.Marshal(accountStorage) + mockKeychain.EXPECT(). + Get(AccountKey). + Return(expectedResult, nil). + AnyTimes() + }) + }) + When("keychain returns error", func() { BeforeEach(func() { mockKeychain.EXPECT(). - Get("account"). - Return([]byte{}, errors.New("")) + Get(AccountKey). + Return([]byte{}, errors.New("")). + AnyTimes() + }) It("returns wrapped error", func() { @@ -65,8 +92,9 @@ var _ = Describe("AppStore (AccountInfo)", func() { When("keychain returns invalid data", func() { BeforeEach(func() { mockKeychain.EXPECT(). - Get("account"). - Return([]byte("..."), nil) + Get(AccountKey). + Return([]byte("..."), nil). + AnyTimes() }) It("fails to unmarshall JSON data", func() { diff --git a/pkg/appstore/appstore_login.go b/pkg/appstore/appstore_login.go index d9b37f0d..82cfeefc 100644 --- a/pkg/appstore/appstore_login.go +++ b/pkg/appstore/appstore_login.go @@ -1,7 +1,6 @@ package appstore import ( - "encoding/json" "errors" "fmt" gohttp "net/http" @@ -105,17 +104,7 @@ func (t *appstore) login(email, password, authCode, guid string) (Account, error Password: password, } - data, err := json.Marshal(acc) - if err != nil { - return Account{}, fmt.Errorf("failed to marshal json: %w", err) - } - - err = t.keychain.Set("account", data) - if err != nil { - return Account{}, fmt.Errorf("failed to save account in keychain: %w", err) - } - - return acc, nil + return t.saveAccount(acc) } func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode string) (bool, string, error) { diff --git a/pkg/appstore/appstore_login_test.go b/pkg/appstore/appstore_login_test.go index 3acf0427..81c7f62f 100644 --- a/pkg/appstore/appstore_login_test.go +++ b/pkg/appstore/appstore_login_test.go @@ -248,9 +248,9 @@ var _ = Describe("AppStore (Login)", func() { When("fails to save account in keychain", func() { BeforeEach(func() { mockKeychain.EXPECT(). - Set("account", gomock.Any()). + Set(AccountKey, gomock.Any()). Do(func(key string, data []byte) { - want := Account{ + wantAccount := Account{ Name: fmt.Sprintf("%s %s", testFirstName, testLastName), Email: testEmail, PasswordToken: testPasswordToken, @@ -258,13 +258,22 @@ var _ = Describe("AppStore (Login)", func() { DirectoryServicesID: testDirectoryServicesID, StoreFront: testStoreFront, } + want := AccountStorage{ + Accounts: []Account{wantAccount}, + Current: testEmail, + } - var got Account + var got AccountStorage err := json.Unmarshal(data, &got) Expect(err).ToNot(HaveOccurred()) Expect(got).To(Equal(want)) }). - Return(errors.New("")) + Return(errors.New("")). + AnyTimes() + mockKeychain.EXPECT(). + Get(AccountKey). + Return([]byte{}, nil). + AnyTimes() }) It("returns error", func() { @@ -278,9 +287,9 @@ var _ = Describe("AppStore (Login)", func() { When("successfully saves account in keychain", func() { BeforeEach(func() { mockKeychain.EXPECT(). - Set("account", gomock.Any()). + Set(AccountKey, gomock.Any()). Do(func(key string, data []byte) { - want := Account{ + wantAccount := Account{ Name: fmt.Sprintf("%s %s", testFirstName, testLastName), Email: testEmail, PasswordToken: testPasswordToken, @@ -289,18 +298,28 @@ var _ = Describe("AppStore (Login)", func() { StoreFront: testStoreFront, } - var got Account + want := AccountStorage{ + Accounts: []Account{wantAccount}, + Current: testEmail, + } + + var got AccountStorage err := json.Unmarshal(data, &got) Expect(err).ToNot(HaveOccurred()) Expect(got).To(Equal(want)) }). - Return(nil) + Return(nil). + AnyTimes() + mockKeychain.EXPECT(). + Get(AccountKey). + AnyTimes() }) It("returns nil", func() { out, err := as.Login(LoginInput{ Password: testPassword, }) + fmt.Println("FK err", err) Expect(err).ToNot(HaveOccurred()) Expect(out.Account.Email).To(Equal(testEmail)) Expect(out.Account.Name).To(Equal(strings.Join([]string{testFirstName, testLastName}, " "))) diff --git a/pkg/appstore/appstore_revoke.go b/pkg/appstore/appstore_revoke.go index 154d88da..e9fa3e71 100644 --- a/pkg/appstore/appstore_revoke.go +++ b/pkg/appstore/appstore_revoke.go @@ -1,14 +1,39 @@ package appstore import ( + "encoding/json" "fmt" ) func (t *appstore) Revoke() error { - err := t.keychain.Remove("account") + var accountStorage AccountStorage + + data, err := t.keychain.Get(AccountKey) if err != nil { return fmt.Errorf("failed to remove account from keychain: %w", err) } + err = json.Unmarshal(data, &accountStorage) + if err != nil { + return fmt.Errorf("failed to unmarshal account data: %w", err) + } + + var remain []Account + for _, acc := range accountStorage.Accounts { + if acc.Email != accountStorage.Current { + remain = append(remain, acc) + } + } + accountStorage.Accounts = remain + + updatedData, err := json.Marshal(accountStorage) + if err != nil { + return fmt.Errorf("failed to marshal updated account data: %w", err) + } + + err = t.keychain.Set(AccountKey, updatedData) + if err != nil { + return fmt.Errorf("failed to update account data in keychain: %w", err) + } return nil } diff --git a/pkg/appstore/appstore_revoke_test.go b/pkg/appstore/appstore_revoke_test.go index 4c4257d7..2cc05409 100644 --- a/pkg/appstore/appstore_revoke_test.go +++ b/pkg/appstore/appstore_revoke_test.go @@ -1,7 +1,7 @@ package appstore import ( - "errors" + "encoding/json" "github.com/majd/ipatool/v2/pkg/keychain" . "github.com/onsi/ginkgo/v2" @@ -16,6 +16,11 @@ var _ = Describe("AppStore (Revoke)", func() { mockKeychain *keychain.MockKeychain ) + var ( + testEmail = "test-email" + testName = "test-name" + ) + BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeychain = keychain.NewMockKeychain(ctrl) @@ -30,9 +35,27 @@ var _ = Describe("AppStore (Revoke)", func() { When("keychain removes item", func() { BeforeEach(func() { + + var accountStorage = AccountStorage{ + Accounts: []Account{ + { + Email: testEmail, + Name: testName, + }, + }, + Current: testEmail, + } + + expectedData, _ := json.Marshal(accountStorage) + mockKeychain.EXPECT(). - Remove("account"). - Return(nil) + Get(AccountKey). + Return(expectedData, nil). + AnyTimes() + mockKeychain.EXPECT(). + Set(AccountKey, gomock.Any()). + Return(nil). + AnyTimes() }) It("returns data", func() { @@ -44,8 +67,13 @@ var _ = Describe("AppStore (Revoke)", func() { When("keychain returns error", func() { BeforeEach(func() { mockKeychain.EXPECT(). - Remove("account"). - Return(errors.New("")) + Get(AccountKey). + Return([]byte("..."), nil). + AnyTimes() + mockKeychain.EXPECT(). + Set(AccountKey, gomock.Any()). + Return(nil). + AnyTimes() }) It("returns wrapped error", func() { diff --git a/pkg/appstore/constants.go b/pkg/appstore/constants.go index d33eb454..611375eb 100644 --- a/pkg/appstore/constants.go +++ b/pkg/appstore/constants.go @@ -25,4 +25,6 @@ const ( PricingParameterAppStore = "STDQ" PricingParameterAppleArcade = "GAME" + + AccountKey = "account" )