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
19 changes: 18 additions & 1 deletion pkg/appstore/appstore_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int
func (t *appstore) loginRequest(email, password, authCode, guid, endpoint string, attempt int) http.Request {
return http.Request{
Method: http.MethodPOST,
URL: endpoint,
URL: authenticateURL(endpoint),
ResponseFormat: http.ResponseFormatXML,
Headers: map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
Expand All @@ -177,3 +177,20 @@ func (t *appstore) loginRequest(email, password, authCode, guid, endpoint string
},
}
}

// authenticateURL normalizes the bag-provided authentication endpoint. Apple's
// current endpoint (https://auth.itunes.apple.com/auth/v1/native/fast) only
// responds correctly when the path has a trailing slash; without it the request
// is redirected/dropped and the login silently fails. The legacy MZFinance
// authenticate endpoint is left untouched.
func authenticateURL(endpoint string) string {
if endpoint == "" {
return endpoint
}

if strings.Contains(endpoint, "/native/") && !strings.HasSuffix(endpoint, "/") {
return endpoint + "/"
}

return endpoint
}
78 changes: 23 additions & 55 deletions pkg/appstore/appstore_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,20 +84,38 @@ var _ = Describe("AppStore (Login)", func() {
})
})

When("store API returns invalid first response", func() {
When("normalizes the native authentication endpoint", 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("stop"))
})

It("appends the trailing slash", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
Endpoint: "https://auth.itunes.apple.com/auth/v1/native/fast",
})
Expect(err).To(HaveOccurred())
})
})

When("store API returns invalid credentials on first attempt", func() {
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[loginResult]{
Data: loginResult{
FailureType: FailureTypeInvalidCredentials,
CustomerMessage: "test",
FailureType: FailureTypeInvalidCredentials,
},
}, nil).
Times(2)
})

It("retries one more time", func() {
It("retries once then returns an error", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expand Down Expand Up @@ -201,24 +219,6 @@ var _ = Describe("AppStore (Login)", func() {
})
})

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"))
})
})

When("store API returns valid response", func() {
const (
testPasswordToken = "test-password-token"
Expand Down Expand Up @@ -249,37 +249,6 @@ var _ = Describe("AppStore (Login)", func() {
}, nil)
})

When("fails to save account in keychain", func() {
BeforeEach(func() {
mockKeychain.EXPECT().
Set("account", gomock.Any()).
Do(func(key string, data []byte) {
want := Account{
Name: fmt.Sprintf("%s %s", testFirstName, testLastName),
Email: testEmail,
PasswordToken: testPasswordToken,
Password: testPassword,
DirectoryServicesID: testDirectoryServicesID,
StoreFront: testStoreFront,
Pod: testPod,
}

var got Account
err := json.Unmarshal(data, &got)
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(want))
}).
Return(errors.New(""))
})

It("returns error", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).To(HaveOccurred())
})
})

When("successfully saves account in keychain", func() {
BeforeEach(func() {
mockKeychain.EXPECT().
Expand All @@ -296,8 +265,7 @@ var _ = Describe("AppStore (Login)", func() {
}

var got Account
err := json.Unmarshal(data, &got)
Expect(err).ToNot(HaveOccurred())
Expect(json.Unmarshal(data, &got)).To(Succeed())
Expect(got).To(Equal(want))
}).
Return(nil)
Expand Down
55 changes: 55 additions & 0 deletions pkg/http/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ var (
documentXMLPattern = regexp.MustCompile(`(?is)<Document\b[^>]*>(.*)</Document>`)
plistXMLPattern = regexp.MustCompile(`(?is)<plist\b[^>]*>.*?</plist>`)
dictXMLPattern = regexp.MustCompile(`(?is)<dict\b[^>]*>.*</dict>`)
htmlTagPattern = regexp.MustCompile(`(?is)<[^>]*>`)
)

//go:generate go run go.uber.org/mock/mockgen -source=client.go -destination=client_mock.go -package=http
Expand Down Expand Up @@ -168,6 +169,15 @@ func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], error) {

normalizedBody := normalizeXMLPlistBody(body)

if !looksLikePropertyList(normalizedBody) {
snippet := bodySnippet(body)
if snippet == "" {
return Result[R]{}, fmt.Errorf("unexpected response from Apple (HTTP %d): empty or non-plist body", res.StatusCode)
}

return Result[R]{}, fmt.Errorf("unexpected response from Apple (HTTP %d): %s", res.StatusCode, snippet)
}

_, err = plist.Unmarshal(normalizedBody, &data)
if err != nil {
return Result[R]{}, fmt.Errorf("failed to unmarshal xml: %w", err)
Expand Down Expand Up @@ -210,6 +220,51 @@ func normalizeXMLPlistBody(body []byte) []byte {
return normalized
}

// looksLikePropertyList reports whether body appears to be a (binary or XML)
// property list. Apple occasionally answers with an HTML error page or a plain
// text message; those must not be handed to plist.Unmarshal, which would
// misinterpret a leading "<h..." as an OpenStep hex-data block and fail with an
// opaque "unexpected hex digit" error instead of surfacing Apple's actual response.
func looksLikePropertyList(body []byte) bool {
trimmed := bytes.TrimSpace(body)
if len(trimmed) == 0 {
return false
}

if bytes.HasPrefix(trimmed, []byte("bplist")) {
return true
}

lower := bytes.ToLower(trimmed)
for _, marker := range [][]byte{
[]byte("<?xml"),
[]byte("<plist"),
[]byte("<dict"),
[]byte("<key"),
} {
if bytes.Contains(lower, marker) {
return true
}
}

return false
}

// bodySnippet returns a compact, single-line excerpt of a non-plist response
// body suitable for embedding in an error message. HTML markup is stripped so
// the underlying message (if any) is readable.
func bodySnippet(body []byte) string {
text := htmlTagPattern.ReplaceAll(body, []byte(" "))
snippet := strings.Join(strings.Fields(string(text)), " ")

const maxLen = 200
if len(snippet) > maxLen {
snippet = snippet[:maxLen] + "…"
}

return snippet
}

func extractEmbeddedPlist(body []byte) []byte {
plistMatch := plistXMLPattern.Find(body)
if len(plistMatch) == 0 {
Expand Down
22 changes: 22 additions & 0 deletions pkg/http/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,28 @@ var _ = Describe("Client", Ordered, func() {
Expect(res.Data.Foo).To(Equal("bar"))
})

It("returns a readable error when Apple responds with an HTML page instead of a plist", func() {
mockHandler = func(w http.ResponseWriter, _r *http.Request) {
w.Header().Add("Content-Type", "text/html")
w.WriteHeader(http.StatusServiceUnavailable)
_, err := w.Write([]byte("<html><body><h1>Service Unavailable</h1></body></html>"))
Expect(err).ToNot(HaveOccurred())
}

sut := NewClient[xmlResult](Args{
CookieJar: mockCookieJar,
})
_, err := sut.Send(Request{
URL: srv.URL,
Method: MethodPOST,
ResponseFormat: ResponseFormatXML,
})

Expect(err).To(HaveOccurred())
Expect(err.Error()).ToNot(ContainSubstring("hex digit"))
Expect(err.Error()).To(ContainSubstring("Service Unavailable"))
})

It("returns error when content type is not supported", func() {
mockHandler = func(w http.ResponseWriter, _r *http.Request) {
w.Header().Add("Content-Type", "application/xyz")
Expand Down
Loading