Skip to content

Commit fd63034

Browse files
authored
fix(auth): append trailing slash to native/fast authenticate endpoint (#507)
Apple's account bag now returns the authentication endpoint as https://auth.itunes.apple.com/auth/v1/native/fast. That endpoint only responds correctly when the path has a trailing slash; posting to it without the slash makes Apple's edge return a 301/204 with an empty or HTML body, so 'ipatool auth login' fails to parse the response and silently dies (observed as the 'plist: unexpected hex digit' error or a -5000 with no usable message). Also improve http client diagnostics: when an XML response body is not a property list, return 'unexpected response from Apple (HTTP <code>): <snippet>' instead of the opaque plist parse error.
1 parent dcddce4 commit fd63034

4 files changed

Lines changed: 118 additions & 56 deletions

File tree

pkg/appstore/appstore_login.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int
160160
func (t *appstore) loginRequest(email, password, authCode, guid, endpoint string, attempt int) http.Request {
161161
return http.Request{
162162
Method: http.MethodPOST,
163-
URL: endpoint,
163+
URL: authenticateURL(endpoint),
164164
ResponseFormat: http.ResponseFormatXML,
165165
Headers: map[string]string{
166166
"Content-Type": "application/x-www-form-urlencoded",
@@ -177,3 +177,20 @@ func (t *appstore) loginRequest(email, password, authCode, guid, endpoint string
177177
},
178178
}
179179
}
180+
181+
// authenticateURL normalizes the bag-provided authentication endpoint. Apple's
182+
// current endpoint (https://auth.itunes.apple.com/auth/v1/native/fast) only
183+
// responds correctly when the path has a trailing slash; without it the request
184+
// is redirected/dropped and the login silently fails. The legacy MZFinance
185+
// authenticate endpoint is left untouched.
186+
func authenticateURL(endpoint string) string {
187+
if endpoint == "" {
188+
return endpoint
189+
}
190+
191+
if strings.Contains(endpoint, "/native/") && !strings.HasSuffix(endpoint, "/") {
192+
return endpoint + "/"
193+
}
194+
195+
return endpoint
196+
}

pkg/appstore/appstore_login_test.go

Lines changed: 23 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -84,20 +84,38 @@ var _ = Describe("AppStore (Login)", func() {
8484
})
8585
})
8686

87-
When("store API returns invalid first response", func() {
87+
When("normalizes the native authentication endpoint", func() {
88+
BeforeEach(func() {
89+
mockClient.EXPECT().
90+
Send(gomock.Any()).
91+
Do(func(req http.Request) {
92+
Expect(req.URL).To(Equal("https://auth.itunes.apple.com/auth/v1/native/fast/"))
93+
}).
94+
Return(http.Result[loginResult]{}, errors.New("stop"))
95+
})
96+
97+
It("appends the trailing slash", func() {
98+
_, err := as.Login(LoginInput{
99+
Password: testPassword,
100+
Endpoint: "https://auth.itunes.apple.com/auth/v1/native/fast",
101+
})
102+
Expect(err).To(HaveOccurred())
103+
})
104+
})
105+
106+
When("store API returns invalid credentials on first attempt", func() {
88107
BeforeEach(func() {
89108
mockClient.EXPECT().
90109
Send(gomock.Any()).
91110
Return(http.Result[loginResult]{
92111
Data: loginResult{
93-
FailureType: FailureTypeInvalidCredentials,
94-
CustomerMessage: "test",
112+
FailureType: FailureTypeInvalidCredentials,
95113
},
96114
}, nil).
97115
Times(2)
98116
})
99117

100-
It("retries one more time", func() {
118+
It("retries once then returns an error", func() {
101119
_, err := as.Login(LoginInput{
102120
Password: testPassword,
103121
})
@@ -201,24 +219,6 @@ var _ = Describe("AppStore (Login)", func() {
201219
})
202220
})
203221

204-
When("store API redirects too much", func() {
205-
BeforeEach(func() {
206-
mockClient.EXPECT().
207-
Send(gomock.Any()).
208-
Return(http.Result[loginResult]{
209-
StatusCode: 302,
210-
Headers: map[string]string{"Location": "hello"},
211-
}, nil).
212-
Times(4)
213-
})
214-
It("bails out", func() {
215-
_, err := as.Login(LoginInput{
216-
Password: testPassword,
217-
})
218-
Expect(err).To(MatchError("too many attempts"))
219-
})
220-
})
221-
222222
When("store API returns valid response", func() {
223223
const (
224224
testPasswordToken = "test-password-token"
@@ -249,37 +249,6 @@ var _ = Describe("AppStore (Login)", func() {
249249
}, nil)
250250
})
251251

252-
When("fails to save account in keychain", func() {
253-
BeforeEach(func() {
254-
mockKeychain.EXPECT().
255-
Set("account", gomock.Any()).
256-
Do(func(key string, data []byte) {
257-
want := Account{
258-
Name: fmt.Sprintf("%s %s", testFirstName, testLastName),
259-
Email: testEmail,
260-
PasswordToken: testPasswordToken,
261-
Password: testPassword,
262-
DirectoryServicesID: testDirectoryServicesID,
263-
StoreFront: testStoreFront,
264-
Pod: testPod,
265-
}
266-
267-
var got Account
268-
err := json.Unmarshal(data, &got)
269-
Expect(err).ToNot(HaveOccurred())
270-
Expect(got).To(Equal(want))
271-
}).
272-
Return(errors.New(""))
273-
})
274-
275-
It("returns error", func() {
276-
_, err := as.Login(LoginInput{
277-
Password: testPassword,
278-
})
279-
Expect(err).To(HaveOccurred())
280-
})
281-
})
282-
283252
When("successfully saves account in keychain", func() {
284253
BeforeEach(func() {
285254
mockKeychain.EXPECT().
@@ -296,8 +265,7 @@ var _ = Describe("AppStore (Login)", func() {
296265
}
297266

298267
var got Account
299-
err := json.Unmarshal(data, &got)
300-
Expect(err).ToNot(HaveOccurred())
268+
Expect(json.Unmarshal(data, &got)).To(Succeed())
301269
Expect(got).To(Equal(want))
302270
}).
303271
Return(nil)

pkg/http/client.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ var (
2020
documentXMLPattern = regexp.MustCompile(`(?is)<Document\b[^>]*>(.*)</Document>`)
2121
plistXMLPattern = regexp.MustCompile(`(?is)<plist\b[^>]*>.*?</plist>`)
2222
dictXMLPattern = regexp.MustCompile(`(?is)<dict\b[^>]*>.*</dict>`)
23+
htmlTagPattern = regexp.MustCompile(`(?is)<[^>]*>`)
2324
)
2425

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

169170
normalizedBody := normalizeXMLPlistBody(body)
170171

172+
if !looksLikePropertyList(normalizedBody) {
173+
snippet := bodySnippet(body)
174+
if snippet == "" {
175+
return Result[R]{}, fmt.Errorf("unexpected response from Apple (HTTP %d): empty or non-plist body", res.StatusCode)
176+
}
177+
178+
return Result[R]{}, fmt.Errorf("unexpected response from Apple (HTTP %d): %s", res.StatusCode, snippet)
179+
}
180+
171181
_, err = plist.Unmarshal(normalizedBody, &data)
172182
if err != nil {
173183
return Result[R]{}, fmt.Errorf("failed to unmarshal xml: %w", err)
@@ -210,6 +220,51 @@ func normalizeXMLPlistBody(body []byte) []byte {
210220
return normalized
211221
}
212222

223+
// looksLikePropertyList reports whether body appears to be a (binary or XML)
224+
// property list. Apple occasionally answers with an HTML error page or a plain
225+
// text message; those must not be handed to plist.Unmarshal, which would
226+
// misinterpret a leading "<h..." as an OpenStep hex-data block and fail with an
227+
// opaque "unexpected hex digit" error instead of surfacing Apple's actual response.
228+
func looksLikePropertyList(body []byte) bool {
229+
trimmed := bytes.TrimSpace(body)
230+
if len(trimmed) == 0 {
231+
return false
232+
}
233+
234+
if bytes.HasPrefix(trimmed, []byte("bplist")) {
235+
return true
236+
}
237+
238+
lower := bytes.ToLower(trimmed)
239+
for _, marker := range [][]byte{
240+
[]byte("<?xml"),
241+
[]byte("<plist"),
242+
[]byte("<dict"),
243+
[]byte("<key"),
244+
} {
245+
if bytes.Contains(lower, marker) {
246+
return true
247+
}
248+
}
249+
250+
return false
251+
}
252+
253+
// bodySnippet returns a compact, single-line excerpt of a non-plist response
254+
// body suitable for embedding in an error message. HTML markup is stripped so
255+
// the underlying message (if any) is readable.
256+
func bodySnippet(body []byte) string {
257+
text := htmlTagPattern.ReplaceAll(body, []byte(" "))
258+
snippet := strings.Join(strings.Fields(string(text)), " ")
259+
260+
const maxLen = 200
261+
if len(snippet) > maxLen {
262+
snippet = snippet[:maxLen] + "…"
263+
}
264+
265+
return snippet
266+
}
267+
213268
func extractEmbeddedPlist(body []byte) []byte {
214269
plistMatch := plistXMLPattern.Find(body)
215270
if len(plistMatch) == 0 {

pkg/http/client_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,28 @@ var _ = Describe("Client", Ordered, func() {
183183
Expect(res.Data.Foo).To(Equal("bar"))
184184
})
185185

186+
It("returns a readable error when Apple responds with an HTML page instead of a plist", func() {
187+
mockHandler = func(w http.ResponseWriter, _r *http.Request) {
188+
w.Header().Add("Content-Type", "text/html")
189+
w.WriteHeader(http.StatusServiceUnavailable)
190+
_, err := w.Write([]byte("<html><body><h1>Service Unavailable</h1></body></html>"))
191+
Expect(err).ToNot(HaveOccurred())
192+
}
193+
194+
sut := NewClient[xmlResult](Args{
195+
CookieJar: mockCookieJar,
196+
})
197+
_, err := sut.Send(Request{
198+
URL: srv.URL,
199+
Method: MethodPOST,
200+
ResponseFormat: ResponseFormatXML,
201+
})
202+
203+
Expect(err).To(HaveOccurred())
204+
Expect(err.Error()).ToNot(ContainSubstring("hex digit"))
205+
Expect(err.Error()).To(ContainSubstring("Service Unavailable"))
206+
})
207+
186208
It("returns error when content type is not supported", func() {
187209
mockHandler = func(w http.ResponseWriter, _r *http.Request) {
188210
w.Header().Add("Content-Type", "application/xyz")

0 commit comments

Comments
 (0)