From f9e021a61b8df5d390793e93a87948d9a5f21309 Mon Sep 17 00:00:00 2001 From: efibalogh Date: Thu, 11 Jun 2026 00:13:19 +0300 Subject: [PATCH] fix: support dynamic App Store auth endpoint discovery Resolve issues with App Store authentication by implementing dynamic auth endpoint discovery. If the initial auth request fails due to endpoint redirects or changes, the client parses the XML plist unmarshal error for any new authentication endpoint and automatically retries the login. Ported from the fix in ipatool (majd/ipatool#490). --- internal/appstore/auth_endpoint.go | 68 ++++++++++++++++++++++++++++++ internal/appstore/bag.go | 7 +-- internal/appstore/http.go | 46 +++++++++++++++++++- internal/appstore/login.go | 10 ++++- internal/appstore/types.go | 3 ++ 5 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 internal/appstore/auth_endpoint.go diff --git a/internal/appstore/auth_endpoint.go b/internal/appstore/auth_endpoint.go new file mode 100644 index 0000000..af4e8dd --- /dev/null +++ b/internal/appstore/auth_endpoint.go @@ -0,0 +1,68 @@ +package appstore + +import ( + "errors" + "fmt" + "html" + "net/url" + "regexp" + "strings" +) + +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 *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/internal/appstore/bag.go b/internal/appstore/bag.go index 69a69bd..a04ca86 100644 --- a/internal/appstore/bag.go +++ b/internal/appstore/bag.go @@ -9,6 +9,7 @@ type bagResult struct { URLBag struct { AuthEndpoint string `plist:"authenticateAccount,omitempty"` } `plist:"urlBag,omitempty"` + AuthEndpoint string `plist:"authenticateAccount,omitempty"` } // bag fetches the App Store bag.xml and returns the authenticate endpoint URL. @@ -32,9 +33,5 @@ func (c *Client) bag() (string, error) { return "", fmt.Errorf("bag: status %d", res.StatusCode) } - if out.URLBag.AuthEndpoint == "" { - return "", fmt.Errorf("bag: no authenticate endpoint") - } - - return out.URLBag.AuthEndpoint, nil + return normalizeAuthEndpoint(out.AuthEndpoint, out.URLBag.AuthEndpoint), nil } diff --git a/internal/appstore/http.go b/internal/appstore/http.go index 893e981..1155547 100644 --- a/internal/appstore/http.go +++ b/internal/appstore/http.go @@ -78,7 +78,13 @@ func (c *Client) send(method, url string, headers map[string]string, body []byte } case formatXML: if _, err := plist.Unmarshal(normalizePlist(data), out); err != nil { - return nil, fmt.Errorf("decode plist: %w", err) + return nil, &ResponseDecodeError{ + Cause: err, + StatusCode: res.StatusCode, + ContentType: res.Header.Get("Content-Type"), + Body: truncateBody(data, 500), + URLs: extractURLs(data), + } } } @@ -124,3 +130,41 @@ func normalizePlist(body []byte) []byte { return n } + +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 +} + +var urlPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) + +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] + "..." +} diff --git a/internal/appstore/login.go b/internal/appstore/login.go index aea5d1c..1c0848a 100644 --- a/internal/appstore/login.go +++ b/internal/appstore/login.go @@ -37,7 +37,8 @@ func (c *Client) Login(email, password, authCode string) (*Account, error) { return nil, err } - url := endpoint + authEndpoint := normalizeAuthEndpoint(endpoint) + url := authEndpoint var ( res *http.Response @@ -63,6 +64,13 @@ func (c *Client) Login(email, password, authCode string) (*Account, error) { "Content-Type": "application/x-www-form-urlencoded", }, body, formatXML, &out) if err != nil { + if discoveredEndpoint := authEndpointFromResponseError(err); discoveredEndpoint != "" && discoveredEndpoint != authEndpoint { + authEndpoint = discoveredEndpoint + url = authEndpoint + + continue + } + return nil, fmt.Errorf("login: %w", err) } diff --git a/internal/appstore/types.go b/internal/appstore/types.go index d5b33d0..c2f0efe 100644 --- a/internal/appstore/types.go +++ b/internal/appstore/types.go @@ -52,6 +52,9 @@ const ( downloadPath = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct" authURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate" + PrivateAuthDomain = "auth." + iTunesDomain + PrivateAuthPathNative = "/auth/v1/native/fast/" + hdrStoreFront = "X-Set-Apple-Store-Front" hdrPod = "pod"