diff --git a/pkg/appstore/appstore_login.go b/pkg/appstore/appstore_login.go index ca5c59d9..642301fc 100644 --- a/pkg/appstore/appstore_login.go +++ b/pkg/appstore/appstore_login.go @@ -4,9 +4,11 @@ import ( "encoding/json" "errors" "fmt" + "strconv" "strings" "github.com/majd/ipatool/v2/pkg/http" + "github.com/majd/ipatool/v2/pkg/util" ) var ( @@ -31,7 +33,7 @@ func (t *appstore) Login(input LoginInput) (LoginOutput, error) { guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") - acc, err := t.login(input.Email, input.Password, input.AuthCode, guid, 0) + acc, err := t.login(input.Email, input.Password, input.AuthCode, guid) if err != nil { return LoginOutput{}, err } @@ -59,28 +61,36 @@ type loginResult struct { PasswordToken string `plist:"passwordToken,omitempty"` } -func (t *appstore) login(email, password, authCode, guid string, attempt int) (Account, error) { - request := t.loginRequest(email, password, authCode, guid) - res, err := t.loginClient.Send(request) - - if err != nil { - return Account{}, fmt.Errorf("request failed: %w", err) - } - - if attempt == 0 && res.Data.FailureType == FailureTypeInvalidCredentials { - return t.login(email, password, authCode, guid, 1) - } - - if res.Data.FailureType != "" && res.Data.CustomerMessage != "" { - return Account{}, NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res) +func (t *appstore) login(email, password, authCode, guid string) (Account, error) { + redirect := "" + var err error + retry := true + var res http.Result[loginResult] + + for attempt := 1; retry && attempt <= 4; attempt++ { + ac := authCode + if attempt == 1 { + ac = "" + } + request := t.loginRequest(email, password, ac, guid, attempt) + request.URL, redirect = util.IfEmpty(redirect, request.URL), "" + res, err = t.loginClient.Send(request) + if err != nil { + return Account{}, fmt.Errorf("request failed: %w", err) + } + + if retry, redirect, err = t.parseLoginResponse(&res, attempt, authCode); err != nil { + return Account{}, err + } } - if res.Data.FailureType != "" { - return Account{}, NewErrorWithMetadata(errors.New("something went wrong"), res) + if retry { + return Account{}, NewErrorWithMetadata(errors.New("too many attempts"), res) } - if res.Data.FailureType == "" && authCode == "" && res.Data.CustomerMessage == CustomerMessageBadLogin { - return Account{}, ErrAuthCodeRequired + sf, err := res.GetHeader(HTTPHeaderStoreFront) + if err != nil { + return Account{}, NewErrorWithMetadata(fmt.Errorf("failed to get storefront header: %w", err), res) } addr := res.Data.Account.Address @@ -89,7 +99,7 @@ func (t *appstore) login(email, password, authCode, guid string, attempt int) (A Email: res.Data.Account.Email, PasswordToken: res.Data.PasswordToken, DirectoryServicesID: res.Data.DirectoryServicesID, - StoreFront: res.Headers[HTTPHeaderStoreFront], + StoreFront: sf, Password: password, } @@ -106,39 +116,46 @@ func (t *appstore) login(email, password, authCode, guid string, attempt int) (A return acc, nil } -func (t *appstore) loginRequest(email, password, authCode, guid string) http.Request { - attempt := "4" - if authCode != "" { - attempt = "2" +func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode string) (retry bool, redirect string, err error) { + if res.StatusCode == 302 { + if redirect, err = res.GetHeader("location"); err != nil { + err = fmt.Errorf("failed to retrieve redirect location: %w", err) + } else { + retry = true + } + } else if attempt == 1 && res.Data.FailureType == FailureTypeInvalidCredentials { + retry = true + } else if res.Data.FailureType == "" && authCode == "" && res.Data.CustomerMessage == CustomerMessageBadLogin { + err = ErrAuthCodeRequired + } else if res.Data.FailureType != "" { + if res.Data.CustomerMessage != "" { + err = NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res) + } else { + err = NewErrorWithMetadata(errors.New("something went wrong"), res) + } + } else if res.StatusCode != 200 || res.Data.PasswordToken == "" || res.Data.DirectoryServicesID == "" { + err = NewErrorWithMetadata(errors.New("something went wrong"), res) } + return +} +func (t *appstore) loginRequest(email, password, authCode, guid string, attempt int) http.Request { return http.Request{ Method: http.MethodPOST, - URL: t.authDomain(authCode, guid), + URL: fmt.Sprintf("https://%s%s", PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathAuthenticate), ResponseFormat: http.ResponseFormatXML, Headers: map[string]string{ "Content-Type": "application/x-www-form-urlencoded", }, Payload: &http.XMLPayload{ Content: map[string]interface{}{ - "appleId": email, - "attempt": attempt, - "createSession": "true", - "guid": guid, - "password": fmt.Sprintf("%s%s", password, authCode), - "rmp": "0", - "why": "signIn", + "appleId": email, + "attempt": strconv.Itoa(attempt), + "guid": guid, + "password": fmt.Sprintf("%s%s", password, authCode), + "rmp": "0", + "why": "signIn", }, }, } } - -func (*appstore) authDomain(authCode, guid string) string { - prefix := PrivateAppStoreAPIDomainPrefixWithoutAuthCode - if authCode != "" { - prefix = PrivateAppStoreAPIDomainPrefixWithAuthCode - } - - return fmt.Sprintf( - "https://%s-%s%s?guid=%s", prefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathAuthenticate, guid) -} diff --git a/pkg/appstore/appstore_login_test.go b/pkg/appstore/appstore_login_test.go index b0310cf6..1fb7ec71 100644 --- a/pkg/appstore/appstore_login_test.go +++ b/pkg/appstore/appstore_login_test.go @@ -135,11 +135,66 @@ var _ = Describe("AppStore (Login)", func() { }, nil) }) - It("returns error", func() { + It("returns ErrAuthCodeRequired error", func() { _, err := as.Login(LoginInput{ Password: testPassword, }) - Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(ErrAuthCodeRequired)) + }) + }) + + When("store API redirects", func() { + const ( + testRedirectLocation = "https://" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathAuthenticate + "?PRH=31&Pod=31" + ) + + BeforeEach(func() { + firstCall := mockClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{})) + x := req.Payload.(*http.XMLPayload) + Expect(x.Content).To(HaveKeyWithValue("attempt", "1")) + }). + Return(http.Result[loginResult]{ + StatusCode: 302, + Headers: map[string]string{"Location": testRedirectLocation}, + }, nil) + secondCall := mockClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(Equal(testRedirectLocation)) + Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{})) + x := req.Payload.(*http.XMLPayload) + Expect(x.Content).To(HaveKeyWithValue("attempt", "2")) + }). + Return(http.Result[loginResult]{}, errors.New("test complete")) + gomock.InOrder(firstCall, secondCall) + }) + + It("follows the redirect and increments attempt", func() { + _, err := as.Login(LoginInput{ + Password: testPassword, + }) + Expect(err).To(MatchError("request failed: test complete")) + }) + }) + + When("store API redirects too much", func() { + BeforeEach(func() { + mockClient.EXPECT(). + Send(gomock.Any()). + Return(http.Result[loginResult]{ + StatusCode: 302, + Headers: map[string]string{"Location": "hello"}, + }, nil). + Times(4) + }) + It("bails out", func() { + _, err := as.Login(LoginInput{ + Password: testPassword, + }) + Expect(err).To(MatchError("too many attempts")) }) }) @@ -147,12 +202,15 @@ var _ = Describe("AppStore (Login)", func() { const ( testPasswordToken = "test-password-token" testDirectoryServicesID = "directory-services-id" + testStoreFront = "test-storefront" ) BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[loginResult]{ + StatusCode: 200, + Headers: map[string]string{HTTPHeaderStoreFront: testStoreFront}, Data: loginResult{ PasswordToken: testPasswordToken, DirectoryServicesID: testDirectoryServicesID, @@ -178,6 +236,7 @@ var _ = Describe("AppStore (Login)", func() { PasswordToken: testPasswordToken, Password: testPassword, DirectoryServicesID: testDirectoryServicesID, + StoreFront: testStoreFront, } var got Account @@ -207,6 +266,7 @@ var _ = Describe("AppStore (Login)", func() { PasswordToken: testPasswordToken, Password: testPassword, DirectoryServicesID: testDirectoryServicesID, + StoreFront: testStoreFront, } var got Account diff --git a/pkg/http/client.go b/pkg/http/client.go index 3ebead93..d9d4a4a7 100644 --- a/pkg/http/client.go +++ b/pkg/http/client.go @@ -11,6 +11,10 @@ import ( "howett.net/plist" ) +const ( + appStoreAuthURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate" +) + //go:generate go run go.uber.org/mock/mockgen -source=client.go -destination=client_mock.go -package=http type Client[R interface{}] interface { Send(request Request) (Result[R], error) @@ -47,8 +51,14 @@ func (t *AddHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error func NewClient[R interface{}](args Args) Client[R] { return &client[R]{ internalClient: http.Client{ - Timeout: 0, - Jar: args.CookieJar, + Timeout: 0, + Jar: args.CookieJar, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if req.Referer() == appStoreAuthURL { + return http.ErrUseLastResponse + } + return nil + }, Transport: &AddHeaderTransport{http.DefaultTransport}, }, cookieJar: args.CookieJar, diff --git a/pkg/http/constants.go b/pkg/http/constants.go index 89f342b3..8f9c9f2f 100644 --- a/pkg/http/constants.go +++ b/pkg/http/constants.go @@ -8,5 +8,5 @@ const ( ) const ( - DefaultUserAgent = "Configurator/2.15 (Macintosh; OS X 11.0.0; 16G29) AppleWebKit/2603.3.8" + DefaultUserAgent = "Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) AppleWebKit/0620.1.16.11.6" ) diff --git a/pkg/http/result.go b/pkg/http/result.go index b35359eb..0afd468c 100644 --- a/pkg/http/result.go +++ b/pkg/http/result.go @@ -1,7 +1,26 @@ package http +import ( + "errors" + "strings" +) + +var ( + ErrHeaderNotFound = errors.New("header not found") +) + type Result[R interface{}] struct { StatusCode int Headers map[string]string Data R } + +func (c *Result[R]) GetHeader(key string) (string, error) { + key = strings.ToLower(key) + for k, v := range c.Headers { + if strings.ToLower(k) == key { + return v, nil + } + } + return "", ErrHeaderNotFound +}