Skip to content

Commit 8a23d6f

Browse files
authored
Fix authentication (#316)
login: increment 'attempt' while following redirects Apple has introduced some dynamic GET parameters into their redirects, forcing us to use the main domain ( no `p71-` and such prefixes ) to obtain those parameters. However, when following such redirects, we shall also increment the `attempt` parameter (something that was hard-coded before).
1 parent 63ee6fc commit 8a23d6f

File tree

5 files changed

+153
-47
lines changed

5 files changed

+153
-47
lines changed

pkg/appstore/appstore_login.go

Lines changed: 59 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import (
44
"encoding/json"
55
"errors"
66
"fmt"
7+
"strconv"
78
"strings"
89

910
"github.com/majd/ipatool/v2/pkg/http"
11+
"github.com/majd/ipatool/v2/pkg/util"
1012
)
1113

1214
var (
@@ -31,7 +33,7 @@ func (t *appstore) Login(input LoginInput) (LoginOutput, error) {
3133

3234
guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")
3335

34-
acc, err := t.login(input.Email, input.Password, input.AuthCode, guid, 0)
36+
acc, err := t.login(input.Email, input.Password, input.AuthCode, guid)
3537
if err != nil {
3638
return LoginOutput{}, err
3739
}
@@ -59,28 +61,36 @@ type loginResult struct {
5961
PasswordToken string `plist:"passwordToken,omitempty"`
6062
}
6163

62-
func (t *appstore) login(email, password, authCode, guid string, attempt int) (Account, error) {
63-
request := t.loginRequest(email, password, authCode, guid)
64-
res, err := t.loginClient.Send(request)
65-
66-
if err != nil {
67-
return Account{}, fmt.Errorf("request failed: %w", err)
68-
}
69-
70-
if attempt == 0 && res.Data.FailureType == FailureTypeInvalidCredentials {
71-
return t.login(email, password, authCode, guid, 1)
72-
}
73-
74-
if res.Data.FailureType != "" && res.Data.CustomerMessage != "" {
75-
return Account{}, NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res)
64+
func (t *appstore) login(email, password, authCode, guid string) (Account, error) {
65+
redirect := ""
66+
var err error
67+
retry := true
68+
var res http.Result[loginResult]
69+
70+
for attempt := 1; retry && attempt <= 4; attempt++ {
71+
ac := authCode
72+
if attempt == 1 {
73+
ac = ""
74+
}
75+
request := t.loginRequest(email, password, ac, guid, attempt)
76+
request.URL, redirect = util.IfEmpty(redirect, request.URL), ""
77+
res, err = t.loginClient.Send(request)
78+
if err != nil {
79+
return Account{}, fmt.Errorf("request failed: %w", err)
80+
}
81+
82+
if retry, redirect, err = t.parseLoginResponse(&res, attempt, authCode); err != nil {
83+
return Account{}, err
84+
}
7685
}
7786

78-
if res.Data.FailureType != "" {
79-
return Account{}, NewErrorWithMetadata(errors.New("something went wrong"), res)
87+
if retry {
88+
return Account{}, NewErrorWithMetadata(errors.New("too many attempts"), res)
8089
}
8190

82-
if res.Data.FailureType == "" && authCode == "" && res.Data.CustomerMessage == CustomerMessageBadLogin {
83-
return Account{}, ErrAuthCodeRequired
91+
sf, err := res.GetHeader(HTTPHeaderStoreFront)
92+
if err != nil {
93+
return Account{}, NewErrorWithMetadata(fmt.Errorf("failed to get storefront header: %w", err), res)
8494
}
8595

8696
addr := res.Data.Account.Address
@@ -89,7 +99,7 @@ func (t *appstore) login(email, password, authCode, guid string, attempt int) (A
8999
Email: res.Data.Account.Email,
90100
PasswordToken: res.Data.PasswordToken,
91101
DirectoryServicesID: res.Data.DirectoryServicesID,
92-
StoreFront: res.Headers[HTTPHeaderStoreFront],
102+
StoreFront: sf,
93103
Password: password,
94104
}
95105

@@ -106,39 +116,46 @@ func (t *appstore) login(email, password, authCode, guid string, attempt int) (A
106116
return acc, nil
107117
}
108118

109-
func (t *appstore) loginRequest(email, password, authCode, guid string) http.Request {
110-
attempt := "4"
111-
if authCode != "" {
112-
attempt = "2"
119+
func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode string) (retry bool, redirect string, err error) {
120+
if res.StatusCode == 302 {
121+
if redirect, err = res.GetHeader("location"); err != nil {
122+
err = fmt.Errorf("failed to retrieve redirect location: %w", err)
123+
} else {
124+
retry = true
125+
}
126+
} else if attempt == 1 && res.Data.FailureType == FailureTypeInvalidCredentials {
127+
retry = true
128+
} else if res.Data.FailureType == "" && authCode == "" && res.Data.CustomerMessage == CustomerMessageBadLogin {
129+
err = ErrAuthCodeRequired
130+
} else if res.Data.FailureType != "" {
131+
if res.Data.CustomerMessage != "" {
132+
err = NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res)
133+
} else {
134+
err = NewErrorWithMetadata(errors.New("something went wrong"), res)
135+
}
136+
} else if res.StatusCode != 200 || res.Data.PasswordToken == "" || res.Data.DirectoryServicesID == "" {
137+
err = NewErrorWithMetadata(errors.New("something went wrong"), res)
113138
}
139+
return
140+
}
114141

142+
func (t *appstore) loginRequest(email, password, authCode, guid string, attempt int) http.Request {
115143
return http.Request{
116144
Method: http.MethodPOST,
117-
URL: t.authDomain(authCode, guid),
145+
URL: fmt.Sprintf("https://%s%s", PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathAuthenticate),
118146
ResponseFormat: http.ResponseFormatXML,
119147
Headers: map[string]string{
120148
"Content-Type": "application/x-www-form-urlencoded",
121149
},
122150
Payload: &http.XMLPayload{
123151
Content: map[string]interface{}{
124-
"appleId": email,
125-
"attempt": attempt,
126-
"createSession": "true",
127-
"guid": guid,
128-
"password": fmt.Sprintf("%s%s", password, authCode),
129-
"rmp": "0",
130-
"why": "signIn",
152+
"appleId": email,
153+
"attempt": strconv.Itoa(attempt),
154+
"guid": guid,
155+
"password": fmt.Sprintf("%s%s", password, authCode),
156+
"rmp": "0",
157+
"why": "signIn",
131158
},
132159
},
133160
}
134161
}
135-
136-
func (*appstore) authDomain(authCode, guid string) string {
137-
prefix := PrivateAppStoreAPIDomainPrefixWithoutAuthCode
138-
if authCode != "" {
139-
prefix = PrivateAppStoreAPIDomainPrefixWithAuthCode
140-
}
141-
142-
return fmt.Sprintf(
143-
"https://%s-%s%s?guid=%s", prefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathAuthenticate, guid)
144-
}

pkg/appstore/appstore_login_test.go

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,24 +135,82 @@ var _ = Describe("AppStore (Login)", func() {
135135
}, nil)
136136
})
137137

138-
It("returns error", func() {
138+
It("returns ErrAuthCodeRequired error", func() {
139139
_, err := as.Login(LoginInput{
140140
Password: testPassword,
141141
})
142-
Expect(err).To(HaveOccurred())
142+
Expect(err).To(Equal(ErrAuthCodeRequired))
143+
})
144+
})
145+
146+
When("store API redirects", func() {
147+
const (
148+
testRedirectLocation = "https://" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathAuthenticate + "?PRH=31&Pod=31"
149+
)
150+
151+
BeforeEach(func() {
152+
firstCall := mockClient.EXPECT().
153+
Send(gomock.Any()).
154+
Do(func(req http.Request) {
155+
Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{}))
156+
x := req.Payload.(*http.XMLPayload)
157+
Expect(x.Content).To(HaveKeyWithValue("attempt", "1"))
158+
}).
159+
Return(http.Result[loginResult]{
160+
StatusCode: 302,
161+
Headers: map[string]string{"Location": testRedirectLocation},
162+
}, nil)
163+
secondCall := mockClient.EXPECT().
164+
Send(gomock.Any()).
165+
Do(func(req http.Request) {
166+
Expect(req.URL).To(Equal(testRedirectLocation))
167+
Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{}))
168+
x := req.Payload.(*http.XMLPayload)
169+
Expect(x.Content).To(HaveKeyWithValue("attempt", "2"))
170+
}).
171+
Return(http.Result[loginResult]{}, errors.New("test complete"))
172+
gomock.InOrder(firstCall, secondCall)
173+
})
174+
175+
It("follows the redirect and increments attempt", func() {
176+
_, err := as.Login(LoginInput{
177+
Password: testPassword,
178+
})
179+
Expect(err).To(MatchError("request failed: test complete"))
180+
})
181+
})
182+
183+
When("store API redirects too much", func() {
184+
BeforeEach(func() {
185+
mockClient.EXPECT().
186+
Send(gomock.Any()).
187+
Return(http.Result[loginResult]{
188+
StatusCode: 302,
189+
Headers: map[string]string{"Location": "hello"},
190+
}, nil).
191+
Times(4)
192+
})
193+
It("bails out", func() {
194+
_, err := as.Login(LoginInput{
195+
Password: testPassword,
196+
})
197+
Expect(err).To(MatchError("too many attempts"))
143198
})
144199
})
145200

146201
When("store API returns valid response", func() {
147202
const (
148203
testPasswordToken = "test-password-token"
149204
testDirectoryServicesID = "directory-services-id"
205+
testStoreFront = "test-storefront"
150206
)
151207

152208
BeforeEach(func() {
153209
mockClient.EXPECT().
154210
Send(gomock.Any()).
155211
Return(http.Result[loginResult]{
212+
StatusCode: 200,
213+
Headers: map[string]string{HTTPHeaderStoreFront: testStoreFront},
156214
Data: loginResult{
157215
PasswordToken: testPasswordToken,
158216
DirectoryServicesID: testDirectoryServicesID,
@@ -178,6 +236,7 @@ var _ = Describe("AppStore (Login)", func() {
178236
PasswordToken: testPasswordToken,
179237
Password: testPassword,
180238
DirectoryServicesID: testDirectoryServicesID,
239+
StoreFront: testStoreFront,
181240
}
182241

183242
var got Account
@@ -207,6 +266,7 @@ var _ = Describe("AppStore (Login)", func() {
207266
PasswordToken: testPasswordToken,
208267
Password: testPassword,
209268
DirectoryServicesID: testDirectoryServicesID,
269+
StoreFront: testStoreFront,
210270
}
211271

212272
var got Account

pkg/http/client.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import (
1111
"howett.net/plist"
1212
)
1313

14+
const (
15+
appStoreAuthURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate"
16+
)
17+
1418
//go:generate go run go.uber.org/mock/mockgen -source=client.go -destination=client_mock.go -package=http
1519
type Client[R interface{}] interface {
1620
Send(request Request) (Result[R], error)
@@ -47,8 +51,14 @@ func (t *AddHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error
4751
func NewClient[R interface{}](args Args) Client[R] {
4852
return &client[R]{
4953
internalClient: http.Client{
50-
Timeout: 0,
51-
Jar: args.CookieJar,
54+
Timeout: 0,
55+
Jar: args.CookieJar,
56+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
57+
if req.Referer() == appStoreAuthURL {
58+
return http.ErrUseLastResponse
59+
}
60+
return nil
61+
},
5262
Transport: &AddHeaderTransport{http.DefaultTransport},
5363
},
5464
cookieJar: args.CookieJar,

pkg/http/constants.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@ const (
88
)
99

1010
const (
11-
DefaultUserAgent = "Configurator/2.15 (Macintosh; OS X 11.0.0; 16G29) AppleWebKit/2603.3.8"
11+
DefaultUserAgent = "Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) AppleWebKit/0620.1.16.11.6"
1212
)

pkg/http/result.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
11
package http
22

3+
import (
4+
"errors"
5+
"strings"
6+
)
7+
8+
var (
9+
ErrHeaderNotFound = errors.New("header not found")
10+
)
11+
312
type Result[R interface{}] struct {
413
StatusCode int
514
Headers map[string]string
615
Data R
716
}
17+
18+
func (c *Result[R]) GetHeader(key string) (string, error) {
19+
key = strings.ToLower(key)
20+
for k, v := range c.Headers {
21+
if strings.ToLower(k) == key {
22+
return v, nil
23+
}
24+
}
25+
return "", ErrHeaderNotFound
26+
}

0 commit comments

Comments
 (0)