Skip to content

Commit fb3cb5e

Browse files
committed
feat: support multiple account
1 parent 91ae69c commit fb3cb5e

10 files changed

Lines changed: 326 additions & 38 deletions

cmd/auth.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ func authCmd() *cobra.Command {
2424
cmd.AddCommand(loginCmd())
2525
cmd.AddCommand(infoCmd())
2626
cmd.AddCommand(revokeCmd())
27+
cmd.AddCommand(listAccountsCmd())
28+
cmd.AddCommand(switchAccountCmd())
2729

2830
return cmd
2931
}
@@ -167,3 +169,70 @@ func revokeCmd() *cobra.Command {
167169
},
168170
}
169171
}
172+
173+
func listAccountsCmd() *cobra.Command {
174+
cmd := &cobra.Command{
175+
Use: "list",
176+
Short: "List saved App Store accounts",
177+
RunE: func(cmd *cobra.Command, args []string) error {
178+
data, err := dependencies.AppStore.AccountsInfo()
179+
if err != nil {
180+
return err
181+
}
182+
183+
for _, acc := range data.Accounts {
184+
dependencies.Logger.Log().
185+
Str("name", acc.Name).
186+
Str("email", acc.Email)
187+
}
188+
return nil
189+
},
190+
}
191+
192+
return cmd
193+
}
194+
195+
// IF provider with -e --email, then switch to that account
196+
// if not provided, list accounts and prompt user to select one
197+
func switchAccountCmd() *cobra.Command {
198+
var email string
199+
cmd := &cobra.Command{
200+
Use: "switch",
201+
Short: "Switch to a different App Store account",
202+
RunE: func(cmd *cobra.Command, args []string) error {
203+
if email == "" {
204+
accounts, err := dependencies.AppStore.AccountsInfo()
205+
if err != nil {
206+
return errors.New("no saved accounts find, please login first")
207+
}
208+
if len(accounts.Accounts) == 0 {
209+
return errors.New("no saved accounts find, please login first")
210+
}
211+
212+
for i, acc := range accounts.Accounts {
213+
fmt.Printf("[%d] %s <%s>\n", i+1, acc.Name, acc.Email)
214+
}
215+
216+
fmt.Print("Select an account by number: ")
217+
var selection int
218+
_, err = fmt.Scanf("%d", &selection)
219+
if err != nil {
220+
return fmt.Errorf("failed to read selection: %w", err)
221+
}
222+
if selection < 1 || selection > len(accounts.Accounts) {
223+
return fmt.Errorf("invalid selection")
224+
}
225+
email = accounts.Accounts[selection-1].Email
226+
fmt.Printf("Switching to account: %s\n", email)
227+
_, err = dependencies.AppStore.SwitchAccount(email)
228+
return err
229+
}
230+
_, err := dependencies.AppStore.SwitchAccount(email)
231+
return err
232+
},
233+
}
234+
235+
cmd.Flags().StringVarP(&email, "email", "e", "", "email address for the Apple ID")
236+
237+
return cmd
238+
}

pkg/appstore/account.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,8 @@ type Account struct {
88
StoreFront string `json:"storeFront,omitempty"`
99
Password string `json:"password,omitempty"`
1010
}
11+
12+
type AccountStorage struct {
13+
Accounts []Account `json:"accounts,omitempty"`
14+
Current string `json:"current,omitempty"`
15+
}

pkg/appstore/appstore.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ type AppStore interface {
1212
Login(input LoginInput) (LoginOutput, error)
1313
// AccountInfo returns the information of the authenticated account.
1414
AccountInfo() (AccountInfoOutput, error)
15+
// AccountsInfo returns the information of all saved accounts.
16+
AccountsInfo() (AccountsInfoOutput, error)
17+
// SwitchAccount switches to the specified account.
18+
SwitchAccount(email string) (Account, error)
1519
// Revoke revokes the active credentials.
1620
Revoke() error
1721
// Lookup looks apps up based on the specified bundle identifier.

pkg/appstore/appstore_account_info.go

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,35 @@ type AccountInfoOutput struct {
99
Account Account
1010
}
1111

12+
type AccountsInfoOutput struct {
13+
Accounts []Account
14+
Current string
15+
}
16+
17+
// Try read as Multiple Accounts storage
18+
// otherwise as single account
1219
func (t *appstore) AccountInfo() (AccountInfoOutput, error) {
13-
data, err := t.keychain.Get("account")
20+
data, err := t.keychain.Get(AccountKey)
1421
if err != nil {
1522
return AccountInfoOutput{}, fmt.Errorf("failed to get account: %w", err)
1623
}
1724

1825
var acc Account
26+
var accounts AccountStorage
27+
28+
err = json.Unmarshal(data, &accounts)
29+
if err == nil && len(accounts.Accounts) > 0 {
30+
// Return current account
31+
for _, a := range accounts.Accounts {
32+
if a.Email == accounts.Current {
33+
return AccountInfoOutput{
34+
Account: a,
35+
}, nil
36+
}
37+
}
38+
}
1939

40+
// Fallback to single account
2041
err = json.Unmarshal(data, &acc)
2142
if err != nil {
2243
return AccountInfoOutput{}, fmt.Errorf("failed to unmarshal json: %w", err)
@@ -26,3 +47,101 @@ func (t *appstore) AccountInfo() (AccountInfoOutput, error) {
2647
Account: acc,
2748
}, nil
2849
}
50+
51+
func (t *appstore) AccountsInfo() (AccountsInfoOutput, error) {
52+
data, err := t.keychain.Get(AccountKey)
53+
if err != nil {
54+
return AccountsInfoOutput{}, fmt.Errorf("failed to get account storage: %w", err)
55+
}
56+
57+
var storage AccountStorage
58+
59+
err = json.Unmarshal(data, &storage)
60+
if err != nil {
61+
return AccountsInfoOutput{}, fmt.Errorf("failed to unmarshal json: %w", err)
62+
}
63+
64+
return AccountsInfoOutput(storage), nil
65+
}
66+
67+
// read from keychain and save to storage
68+
// if is v2 data, just save it
69+
// if is old data, convert and save it
70+
func (t *appstore) saveAccount(acc Account) (Account, error) {
71+
72+
var accountStorage AccountStorage
73+
var accInKeychain Account
74+
75+
rootData, err := t.keychain.Get(AccountKey)
76+
if err != nil {
77+
// Ignore error if account does not exist yet
78+
return Account{}, err
79+
}
80+
err = json.Unmarshal(rootData, &accountStorage)
81+
82+
if err != nil {
83+
err = json.Unmarshal(rootData, &accInKeychain)
84+
if err == nil {
85+
accountStorage = AccountStorage{
86+
Accounts: []Account{accInKeychain},
87+
Current: accInKeychain.Email,
88+
}
89+
}
90+
}
91+
92+
// handle deduplicate accounts
93+
var found bool
94+
for _, a := range accountStorage.Accounts {
95+
if a.Email == acc.Email {
96+
found = true
97+
break
98+
}
99+
}
100+
if !found {
101+
accountStorage.Accounts = append(accountStorage.Accounts, acc)
102+
}
103+
accountStorage.Current = acc.Email
104+
105+
rootData, err = json.Marshal(accountStorage)
106+
if err != nil {
107+
return Account{}, fmt.Errorf("failed to marshal json: %w", err)
108+
}
109+
err = t.keychain.Set(AccountKey, rootData)
110+
if err != nil {
111+
return Account{}, fmt.Errorf("failed to save account storage in keychain: %w", err)
112+
}
113+
return acc, nil
114+
}
115+
116+
func (t *appstore) SwitchAccount(email string) (Account, error) {
117+
accountStorage, err := t.AccountsInfo()
118+
if err != nil {
119+
return Account{}, fmt.Errorf("failed to get accounts info: %w", err)
120+
}
121+
122+
var found bool
123+
var res Account
124+
for _, acc := range accountStorage.Accounts {
125+
if acc.Email == email {
126+
found = true
127+
res = acc
128+
break
129+
}
130+
}
131+
if !found {
132+
return Account{}, fmt.Errorf("account with email %s not found", email)
133+
}
134+
135+
accountStorage.Current = email
136+
137+
rootData, err := json.Marshal(accountStorage)
138+
if err != nil {
139+
return Account{}, fmt.Errorf("failed to marshal json: %w", err)
140+
}
141+
err = t.keychain.Set(AccountKey, rootData)
142+
if err != nil {
143+
return Account{}, fmt.Errorf("failed to save account storage in keychain: %w", err)
144+
}
145+
146+
return res, nil
147+
}

pkg/appstore/appstore_account_info_test.go

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
package appstore
22

33
import (
4+
"encoding/json"
45
"errors"
5-
"fmt"
66

77
"github.com/majd/ipatool/v2/pkg/keychain"
88
. "github.com/onsi/ginkgo/v2"
@@ -17,6 +17,11 @@ var _ = Describe("AppStore (AccountInfo)", func() {
1717
mockKeychain *keychain.MockKeychain
1818
)
1919

20+
var (
21+
testEmail = "test-email"
22+
testName = "test-name"
23+
)
24+
2025
BeforeEach(func() {
2126
ctrl = gomock.NewController(GinkgoT())
2227
mockKeychain = keychain.NewMockKeychain(ctrl)
@@ -30,15 +35,17 @@ var _ = Describe("AppStore (AccountInfo)", func() {
3035
})
3136

3237
When("keychain returns valid data", func() {
33-
const (
34-
testEmail = "test-email"
35-
testName = "test-name"
36-
)
3738

3839
BeforeEach(func() {
40+
var defaultAccount = Account{
41+
Email: testEmail,
42+
Name: testName,
43+
}
44+
var expectedResult, _ = json.Marshal(defaultAccount)
3945
mockKeychain.EXPECT().
40-
Get("account").
41-
Return([]byte(fmt.Sprintf("{\"email\": \"%s\", \"name\": \"%s\"}", testEmail, testName)), nil)
46+
Get(AccountKey).
47+
Return(expectedResult, nil).
48+
AnyTimes()
4249
})
4350

4451
It("returns output", func() {
@@ -49,11 +56,31 @@ var _ = Describe("AppStore (AccountInfo)", func() {
4956
})
5057
})
5158

59+
When("keychain returns new version valid data", func() {
60+
BeforeEach(func() {
61+
var defaultAccount = Account{
62+
Email: testEmail,
63+
Name: testName,
64+
}
65+
var accountStorage = AccountStorage{
66+
Current: testEmail,
67+
Accounts: []Account{defaultAccount},
68+
}
69+
var expectedResult, _ = json.Marshal(accountStorage)
70+
mockKeychain.EXPECT().
71+
Get(AccountKey).
72+
Return(expectedResult, nil).
73+
AnyTimes()
74+
})
75+
})
76+
5277
When("keychain returns error", func() {
5378
BeforeEach(func() {
5479
mockKeychain.EXPECT().
55-
Get("account").
56-
Return([]byte{}, errors.New(""))
80+
Get(AccountKey).
81+
Return([]byte{}, errors.New("")).
82+
AnyTimes()
83+
5784
})
5885

5986
It("returns wrapped error", func() {
@@ -65,8 +92,9 @@ var _ = Describe("AppStore (AccountInfo)", func() {
6592
When("keychain returns invalid data", func() {
6693
BeforeEach(func() {
6794
mockKeychain.EXPECT().
68-
Get("account").
69-
Return([]byte("..."), nil)
95+
Get(AccountKey).
96+
Return([]byte("..."), nil).
97+
AnyTimes()
7098
})
7199

72100
It("fails to unmarshall JSON data", func() {

pkg/appstore/appstore_login.go

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package appstore
22

33
import (
4-
"encoding/json"
54
"errors"
65
"fmt"
76
gohttp "net/http"
@@ -105,17 +104,7 @@ func (t *appstore) login(email, password, authCode, guid string) (Account, error
105104
Password: password,
106105
}
107106

108-
data, err := json.Marshal(acc)
109-
if err != nil {
110-
return Account{}, fmt.Errorf("failed to marshal json: %w", err)
111-
}
112-
113-
err = t.keychain.Set("account", data)
114-
if err != nil {
115-
return Account{}, fmt.Errorf("failed to save account in keychain: %w", err)
116-
}
117-
118-
return acc, nil
107+
return t.saveAccount(acc)
119108
}
120109

121110
func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode string) (bool, string, error) {

0 commit comments

Comments
 (0)