Skip to content

Commit f2bb3dc

Browse files
authored
Support dynamic App Store auth endpoint discovery (#29)
1 parent 3b681c7 commit f2bb3dc

5 files changed

Lines changed: 127 additions & 7 deletions

File tree

internal/appstore/auth_endpoint.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package appstore
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"html"
7+
"net/url"
8+
"regexp"
9+
"strings"
10+
)
11+
12+
var authEndpointURLPattern = regexp.MustCompile(`https?://[^\s"'<>]+`)
13+
14+
func normalizeAuthEndpoint(endpoints ...string) string {
15+
for _, endpoint := range endpoints {
16+
endpoint = strings.TrimSpace(endpoint)
17+
if endpoint == "" {
18+
continue
19+
}
20+
21+
normalized := normalizeNativeAuthEndpoint(endpoint)
22+
if normalized != "" {
23+
return normalized
24+
}
25+
26+
return endpoint
27+
}
28+
29+
return fmt.Sprintf("https://%s%s", PrivateAuthDomain, PrivateAuthPathNative)
30+
}
31+
32+
func authEndpointFromResponseError(err error) string {
33+
var decodeErr *ResponseDecodeError
34+
if !errors.As(err, &decodeErr) {
35+
return ""
36+
}
37+
38+
return authEndpointFromText(strings.Join(append(decodeErr.URLs, decodeErr.Body), " "))
39+
}
40+
41+
func authEndpointFromText(text string) string {
42+
text = html.UnescapeString(strings.ReplaceAll(text, `\/`, `/`))
43+
44+
matches := authEndpointURLPattern.FindAllString(text, -1)
45+
for _, match := range matches {
46+
if endpoint := normalizeNativeAuthEndpoint(strings.TrimRight(match, ".,;)")); endpoint != "" {
47+
return endpoint
48+
}
49+
}
50+
51+
return ""
52+
}
53+
54+
func normalizeNativeAuthEndpoint(endpoint string) string {
55+
parsed, err := url.Parse(endpoint)
56+
if err != nil || parsed.Host != PrivateAuthDomain {
57+
return ""
58+
}
59+
60+
path := strings.TrimRight(parsed.Path, "/")
61+
if !strings.HasSuffix(path, "/fast") {
62+
path = strings.TrimRight(path, "/") + "/fast"
63+
}
64+
65+
parsed.Path = path + "/"
66+
67+
return parsed.String()
68+
}

internal/appstore/bag.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type bagResult struct {
99
URLBag struct {
1010
AuthEndpoint string `plist:"authenticateAccount,omitempty"`
1111
} `plist:"urlBag,omitempty"`
12+
AuthEndpoint string `plist:"authenticateAccount,omitempty"`
1213
}
1314

1415
// bag fetches the App Store bag.xml and returns the authenticate endpoint URL.
@@ -32,9 +33,5 @@ func (c *Client) bag() (string, error) {
3233
return "", fmt.Errorf("bag: status %d", res.StatusCode)
3334
}
3435

35-
if out.URLBag.AuthEndpoint == "" {
36-
return "", fmt.Errorf("bag: no authenticate endpoint")
37-
}
38-
39-
return out.URLBag.AuthEndpoint, nil
36+
return normalizeAuthEndpoint(out.AuthEndpoint, out.URLBag.AuthEndpoint), nil
4037
}

internal/appstore/http.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,13 @@ func (c *Client) send(method, url string, headers map[string]string, body []byte
7878
}
7979
case formatXML:
8080
if _, err := plist.Unmarshal(normalizePlist(data), out); err != nil {
81-
return nil, fmt.Errorf("decode plist: %w", err)
81+
return nil, &ResponseDecodeError{
82+
Cause: err,
83+
StatusCode: res.StatusCode,
84+
ContentType: res.Header.Get("Content-Type"),
85+
Body: truncateBody(data, 500),
86+
URLs: extractURLs(data),
87+
}
8288
}
8389
}
8490

@@ -124,3 +130,41 @@ func normalizePlist(body []byte) []byte {
124130

125131
return n
126132
}
133+
134+
type ResponseDecodeError struct {
135+
Cause error
136+
StatusCode int
137+
ContentType string
138+
Body string
139+
URLs []string
140+
}
141+
142+
func (e *ResponseDecodeError) Error() string {
143+
return fmt.Sprintf("failed to unmarshal xml: %v", e.Cause)
144+
}
145+
146+
func (e *ResponseDecodeError) Unwrap() error {
147+
return e.Cause
148+
}
149+
150+
var urlPattern = regexp.MustCompile(`https?://[^\s"'<>]+`)
151+
152+
func extractURLs(body []byte) []string {
153+
matches := urlPattern.FindAll(body, -1)
154+
155+
urls := make([]string, 0, len(matches))
156+
for _, match := range matches {
157+
urls = append(urls, string(match))
158+
}
159+
160+
return urls
161+
}
162+
163+
func truncateBody(body []byte, max int) string {
164+
trimmed := strings.TrimSpace(string(body))
165+
if len(trimmed) <= max {
166+
return trimmed
167+
}
168+
169+
return trimmed[:max] + "..."
170+
}

internal/appstore/login.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ func (c *Client) Login(email, password, authCode string) (*Account, error) {
3737
return nil, err
3838
}
3939

40-
url := endpoint
40+
authEndpoint := normalizeAuthEndpoint(endpoint)
41+
url := authEndpoint
4142

4243
var (
4344
res *http.Response
@@ -63,6 +64,13 @@ func (c *Client) Login(email, password, authCode string) (*Account, error) {
6364
"Content-Type": "application/x-www-form-urlencoded",
6465
}, body, formatXML, &out)
6566
if err != nil {
67+
if discoveredEndpoint := authEndpointFromResponseError(err); discoveredEndpoint != "" && discoveredEndpoint != authEndpoint {
68+
authEndpoint = discoveredEndpoint
69+
url = authEndpoint
70+
71+
continue
72+
}
73+
6674
return nil, fmt.Errorf("login: %w", err)
6775
}
6876

internal/appstore/types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ const (
5252
downloadPath = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct"
5353
authURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate"
5454

55+
PrivateAuthDomain = "auth." + iTunesDomain
56+
PrivateAuthPathNative = "/auth/v1/native/fast/"
57+
5558
hdrStoreFront = "X-Set-Apple-Store-Front"
5659
hdrPod = "pod"
5760

0 commit comments

Comments
 (0)