Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions internal/appstore/auth_endpoint.go
Original file line number Diff line number Diff line change
@@ -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()
}
7 changes: 2 additions & 5 deletions internal/appstore/bag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
46 changes: 45 additions & 1 deletion internal/appstore/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}
}

Expand Down Expand Up @@ -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] + "..."
}
10 changes: 9 additions & 1 deletion internal/appstore/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}

Expand Down
3 changes: 3 additions & 0 deletions internal/appstore/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading