diff --git a/pkg/appstore/appstore_bag.go b/pkg/appstore/appstore_bag.go index 29108a2f..8de570c5 100644 --- a/pkg/appstore/appstore_bag.go +++ b/pkg/appstore/appstore_bag.go @@ -33,12 +33,13 @@ func (t *appstore) Bag(input BagInput) (BagOutput, error) { } return BagOutput{ - AuthEndpoint: res.Data.URLBag.AuthEndpoint, + AuthEndpoint: normalizeAuthEndpoint(res.Data.AuthEndpoint, res.Data.URLBag.AuthEndpoint), }, nil } type bagResult struct { - URLBag urlBag `plist:"urlBag,omitempty"` + URLBag urlBag `plist:"urlBag,omitempty"` + AuthEndpoint string `plist:"authenticateAccount,omitempty"` } type urlBag struct { diff --git a/pkg/appstore/appstore_bag_test.go b/pkg/appstore/appstore_bag_test.go index ae9f1f27..15d94aed 100644 --- a/pkg/appstore/appstore_bag_test.go +++ b/pkg/appstore/appstore_bag_test.go @@ -118,6 +118,29 @@ var _ = Describe("AppStore (Bag)", func() { }) }) + When("request is successful with authenticateAccount at the root", func() { + BeforeEach(func() { + mockMachine.EXPECT(). + MacAddress(). + Return("aa:bb:cc:dd:ee:ff", nil) + + mockBagClient.EXPECT(). + Send(gomock.Any()). + Return(http.Result[bagResult]{ + StatusCode: gohttp.StatusOK, + Data: bagResult{ + AuthEndpoint: "https://auth.itunes.apple.com/auth/v1/native", + }, + }, nil) + }) + + It("returns the normalized native auth endpoint", func() { + out, err := as.Bag(BagInput{}) + Expect(err).ToNot(HaveOccurred()) + Expect(out.AuthEndpoint).To(Equal("https://auth.itunes.apple.com/auth/v1/native/fast/")) + }) + }) + When("request is successful but authenticateAccount is empty", func() { BeforeEach(func() { mockMachine.EXPECT(). @@ -132,10 +155,10 @@ var _ = Describe("AppStore (Bag)", func() { }, nil) }) - It("returns empty auth endpoint", func() { + It("returns the fallback auth endpoint", func() { out, err := as.Bag(BagInput{}) Expect(err).ToNot(HaveOccurred()) - Expect(out.AuthEndpoint).To(BeEmpty()) + Expect(out.AuthEndpoint).To(Equal("https://auth.itunes.apple.com/auth/v1/native/fast/")) }) }) }) diff --git a/pkg/appstore/appstore_login.go b/pkg/appstore/appstore_login.go index 419fdeaa..cc0326d0 100644 --- a/pkg/appstore/appstore_login.go +++ b/pkg/appstore/appstore_login.go @@ -65,6 +65,7 @@ type loginResult struct { func (t *appstore) login(email, password, authCode, guid, endpoint string) (Account, error) { redirect := "" + authEndpoint := normalizeAuthEndpoint(endpoint) var ( err error @@ -74,11 +75,18 @@ func (t *appstore) login(email, password, authCode, guid, endpoint string) (Acco retry := true for attempt := 1; retry && attempt <= 4; attempt++ { - request := t.loginRequest(email, password, authCode, guid, endpoint, attempt) + request := t.loginRequest(email, password, authCode, guid, authEndpoint, attempt) request.URL, _ = util.IfEmpty(redirect, request.URL), "" res, err = t.loginClient.Send(request) if err != nil { + if discoveredEndpoint := authEndpointFromResponseError(err); discoveredEndpoint != "" && discoveredEndpoint != authEndpoint { + authEndpoint = discoveredEndpoint + redirect = "" + retry = true + continue + } + return Account{}, fmt.Errorf("request failed: %w", err) } diff --git a/pkg/appstore/appstore_login_test.go b/pkg/appstore/appstore_login_test.go index 3521eeb1..d6b7706c 100644 --- a/pkg/appstore/appstore_login_test.go +++ b/pkg/appstore/appstore_login_test.go @@ -73,6 +73,9 @@ var _ = Describe("AppStore (Login)", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(Equal("https://auth.itunes.apple.com/auth/v1/native/fast/")) + }). Return(http.Result[loginResult]{}, errors.New("")) }) @@ -201,6 +204,32 @@ var _ = Describe("AppStore (Login)", func() { }) }) + When("store API response contains a new auth endpoint", func() { + BeforeEach(func() { + firstCall := mockClient.EXPECT(). + Send(gomock.Any()). + Return(http.Result[loginResult]{}, &http.ResponseDecodeError{ + Cause: errors.New("decode failed"), + URLs: []string{"https://auth.itunes.apple.com/auth/v1/native"}, + }) + secondCall := mockClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(Equal("https://auth.itunes.apple.com/auth/v1/native/fast/")) + }). + Return(http.Result[loginResult]{}, errors.New("test complete")) + gomock.InOrder(firstCall, secondCall) + }) + + It("retries with the discovered endpoint", func() { + _, err := as.Login(LoginInput{ + Endpoint: "https://example.com/authenticate", + Password: testPassword, + }) + Expect(err).To(MatchError(ContainSubstring("test complete"))) + }) + }) + When("store API redirects too much", func() { BeforeEach(func() { mockClient.EXPECT(). diff --git a/pkg/appstore/auth_endpoint.go b/pkg/appstore/auth_endpoint.go new file mode 100644 index 00000000..470d00e6 --- /dev/null +++ b/pkg/appstore/auth_endpoint.go @@ -0,0 +1,68 @@ +package appstore + +import ( + "errors" + "fmt" + "html" + "net/url" + "regexp" + "strings" + + "github.com/majd/ipatool/v2/pkg/http" +) + +var authEndpointURLPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) + +func normalizeAuthEndpoint(endpoints ...string) string { + for _, endpoint := range endpoints { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + continue + } + + normalized := normalizeNativeAuthEndpoint(endpoint) + if normalized != "" { + return normalized + } + + return endpoint + } + + return fmt.Sprintf("https://%s%s", PrivateAuthDomain, PrivateAuthPathNative) +} + +func authEndpointFromResponseError(err error) string { + var decodeErr *http.ResponseDecodeError + if !errors.As(err, &decodeErr) { + return "" + } + + return authEndpointFromText(strings.Join(append(decodeErr.URLs, decodeErr.Body), " ")) +} + +func authEndpointFromText(text string) string { + text = html.UnescapeString(strings.ReplaceAll(text, `\/`, `/`)) + matches := authEndpointURLPattern.FindAllString(text, -1) + for _, match := range matches { + if endpoint := normalizeNativeAuthEndpoint(strings.TrimRight(match, ".,;)")); endpoint != "" { + return endpoint + } + } + + return "" +} + +func normalizeNativeAuthEndpoint(endpoint string) string { + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Host != PrivateAuthDomain { + return "" + } + + path := strings.TrimRight(parsed.Path, "/") + if !strings.HasSuffix(path, "/fast") { + path = strings.TrimRight(path, "/") + "/fast" + } + parsed.Path = path + "/" + + return parsed.String() +} diff --git a/pkg/appstore/auth_endpoint_test.go b/pkg/appstore/auth_endpoint_test.go new file mode 100644 index 00000000..b9ed5d6f --- /dev/null +++ b/pkg/appstore/auth_endpoint_test.go @@ -0,0 +1,40 @@ +package appstore + +import ( + "errors" + + "github.com/majd/ipatool/v2/pkg/http" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Auth endpoint", func() { + It("falls back to the native auth endpoint", func() { + Expect(normalizeAuthEndpoint()).To(Equal("https://auth.itunes.apple.com/auth/v1/native/fast/")) + }) + + It("normalizes Apple's native auth endpoint with the fast path and trailing slash", func() { + Expect(normalizeAuthEndpoint("https://auth.itunes.apple.com/auth/v1/native")). + To(Equal("https://auth.itunes.apple.com/auth/v1/native/fast/")) + Expect(normalizeAuthEndpoint("https://auth.itunes.apple.com/auth/v1/native/fast")). + To(Equal("https://auth.itunes.apple.com/auth/v1/native/fast/")) + }) + + It("keeps legacy endpoints unchanged", func() { + endpoint := "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate" + Expect(normalizeAuthEndpoint(endpoint)).To(Equal(endpoint)) + }) + + It("extracts a native endpoint from an escaped response body", func() { + body := `{"authenticateAccount":"https:\/\/auth.itunes.apple.com\/auth\/v1\/native"}` + Expect(authEndpointFromText(body)).To(Equal("https://auth.itunes.apple.com/auth/v1/native/fast/")) + }) + + It("extracts a native endpoint from a response decode error", func() { + err := &http.ResponseDecodeError{ + Cause: errors.New("decode failed"), + URLs: []string{"https://auth.itunes.apple.com/auth/v1/native"}, + } + Expect(authEndpointFromResponseError(err)).To(Equal("https://auth.itunes.apple.com/auth/v1/native/fast/")) + }) +}) diff --git a/pkg/appstore/constants.go b/pkg/appstore/constants.go index 66201514..62c257a1 100644 --- a/pkg/appstore/constants.go +++ b/pkg/appstore/constants.go @@ -24,6 +24,8 @@ const ( PrivateAppStoreAPIDomain = "buy." + iTunesAPIDomain PrivateAppStoreAPIPathPurchase = "/WebObjects/MZFinance.woa/wa/buyProduct" PrivateAppStoreAPIPathDownload = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct" + PrivateAuthDomain = "auth." + iTunesAPIDomain + PrivateAuthPathNative = "/auth/v1/native/fast/" HTTPHeaderStoreFront = "X-Set-Apple-Store-Front" HTTPHeaderPod = "pod" diff --git a/pkg/http/client.go b/pkg/http/client.go index 22de26b1..9e196bed 100644 --- a/pkg/http/client.go +++ b/pkg/http/client.go @@ -20,8 +20,25 @@ var ( documentXMLPattern = regexp.MustCompile(`(?is)]*>(.*)`) plistXMLPattern = regexp.MustCompile(`(?is)]*>.*?`) dictXMLPattern = regexp.MustCompile(`(?is)]*>.*`) + urlPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) ) +type ResponseDecodeError struct { + Cause error + StatusCode int + ContentType string + Body string + URLs []string +} + +func (e *ResponseDecodeError) Error() string { + return fmt.Sprintf("failed to unmarshal xml: %v", e.Cause) +} + +func (e *ResponseDecodeError) Unwrap() error { + return e.Cause +} + //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) @@ -170,7 +187,13 @@ func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], error) { _, err = plist.Unmarshal(normalizedBody, &data) if err != nil { - return Result[R]{}, fmt.Errorf("failed to unmarshal xml: %w", err) + return Result[R]{}, &ResponseDecodeError{ + Cause: err, + StatusCode: res.StatusCode, + ContentType: res.Header.Get("Content-Type"), + Body: truncateBody(body, 500), + URLs: extractURLs(body), + } } headers := map[string]string{} @@ -185,6 +208,25 @@ func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], error) { }, nil } +func extractURLs(body []byte) []string { + matches := urlPattern.FindAll(body, -1) + urls := make([]string, 0, len(matches)) + for _, match := range matches { + urls = append(urls, string(match)) + } + + return urls +} + +func truncateBody(body []byte, max int) string { + trimmed := strings.TrimSpace(string(body)) + if len(trimmed) <= max { + return trimmed + } + + return trimmed[:max] + "..." +} + func normalizeXMLPlistBody(body []byte) []byte { normalized := bytes.TrimSpace(body) if len(normalized) == 0 {