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
33 changes: 32 additions & 1 deletion pkg/appstore/appstore_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ var (
ErrAuthCodeRequired = errors.New("auth code is required")
)

const legacyAuthenticateEndpoint = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate"

type LoginInput struct {
Email string
Password string
Expand Down Expand Up @@ -74,11 +76,22 @@ func (t *appstore) login(email, password, authCode, guid, endpoint string) (Acco
retry := true

for attempt := 1; retry && attempt <= 4; attempt++ {
request := t.loginRequest(email, password, authCode, guid, endpoint, attempt)
requestAttempt := attempt
if redirect != "" {
// The pod redirect is part of the same authentication attempt. Apple
// expects the original XML plist body, including its attempt value.
requestAttempt = 1
}

request := t.loginRequest(email, password, authCode, guid, endpoint, requestAttempt)
request.URL, _ = util.IfEmpty(redirect, request.URL), ""
res, err = t.loginClient.Send(request)

if err != nil {
if shouldRetryWithLegacyAuthenticate(endpoint, err) {
return t.login(email, password, authCode, guid, legacyAuthenticateEndpoint)
}

return Account{}, fmt.Errorf("request failed: %w", err)
}

Expand Down Expand Up @@ -125,6 +138,24 @@ func (t *appstore) login(email, password, authCode, guid, endpoint string) (Acco
return acc, nil
}

func shouldRetryWithLegacyAuthenticate(endpoint string, err error) bool {
if !strings.Contains(endpoint, "/native/") {
return false
}

var responseErr *http.UnexpectedResponseError
if !errors.As(err, &responseErr) {
return false
}

switch responseErr.StatusCode {
case gohttp.StatusNoContent, gohttp.StatusForbidden, gohttp.StatusNotFound, gohttp.StatusServiceUnavailable:
return true
default:
return false
}
}

func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode string) (bool, string, error) {
var (
retry bool
Expand Down
45 changes: 43 additions & 2 deletions pkg/appstore/appstore_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,47 @@ var _ = Describe("AppStore (Login)", func() {
})
})

When("native authentication returns an empty response", func() {
const podURL = "https://p7-buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate?Pod=7&PRH=7"

BeforeEach(func() {
native := 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]{}, &http.UnexpectedResponseError{StatusCode: 204})
legacy := mockClient.EXPECT().
Send(gomock.Any()).
Do(func(req http.Request) {
Expect(req.URL).To(Equal(legacyAuthenticateEndpoint))
payload := req.Payload.(*http.XMLPayload)
Expect(payload.Content).To(HaveKeyWithValue("attempt", "1"))
}).
Return(http.Result[loginResult]{
StatusCode: 302,
Headers: map[string]string{"Location": podURL},
}, nil)
pod := mockClient.EXPECT().
Send(gomock.Any()).
Do(func(req http.Request) {
Expect(req.URL).To(Equal(podURL))
payload := req.Payload.(*http.XMLPayload)
Expect(payload.Content).To(HaveKeyWithValue("attempt", "1"))
}).
Return(http.Result[loginResult]{}, errors.New("stop after pod redirect"))
gomock.InOrder(native, legacy, pod)
})

It("falls back to legacy authentication and reposts the plist to the assigned pod", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
Endpoint: "https://auth.itunes.apple.com/auth/v1/native/fast",
})
Expect(err).To(MatchError("request failed: stop after pod redirect"))
})
})

When("store API returns invalid credentials on first attempt", func() {
BeforeEach(func() {
mockClient.EXPECT().
Expand Down Expand Up @@ -205,13 +246,13 @@ var _ = Describe("AppStore (Login)", func() {
Expect(req.URL).To(Equal(testRedirectLocation))
Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{}))
x := req.Payload.(*http.XMLPayload)
Expect(x.Content).To(HaveKeyWithValue("attempt", "2"))
Expect(x.Content).To(HaveKeyWithValue("attempt", "1"))
}).
Return(http.Result[loginResult]{}, errors.New("test complete"))
gomock.InOrder(firstCall, secondCall)
})

It("follows the redirect and increments attempt", func() {
It("follows the redirect while preserving the original request body", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expand Down
47 changes: 38 additions & 9 deletions pkg/http/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ type Args struct {
CookieJar CookieJar
}

// UnexpectedResponseError preserves the HTTP status when Apple returns an
// HTML or empty response where an XML plist was expected.
type UnexpectedResponseError struct {
StatusCode int
Snippet string
}

func (e *UnexpectedResponseError) Error() string {
if e.Snippet == "" {
return fmt.Sprintf("unexpected response from Apple (HTTP %d): empty or non-plist body", e.StatusCode)
}

return fmt.Sprintf("unexpected response from Apple (HTTP %d): %s", e.StatusCode, e.Snippet)
}

type AddHeaderTransport struct {
T http.RoundTripper
}
Expand Down Expand Up @@ -165,34 +180,48 @@ func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], error) {
return Result[R]{}, fmt.Errorf("rate limited by Apple (HTTP %d): %s", res.StatusCode, strings.TrimSpace(string(body)))
}

// The legacy authentication endpoint redirects to an assigned Store pod.
// Preserve the redirect response and its Location header so callers can
// repeat the original POST request at that pod.
if res.StatusCode >= http.StatusMultipleChoices && res.StatusCode < http.StatusBadRequest {
return Result[R]{
StatusCode: res.StatusCode,
Headers: responseHeaders(res),
}, nil
}

var data R

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)
return Result[R]{}, &UnexpectedResponseError{
StatusCode: res.StatusCode,
Snippet: snippet,
}
}

_, err = plist.Unmarshal(normalizedBody, &data)
if err != nil {
return Result[R]{}, fmt.Errorf("failed to unmarshal xml: %w", err)
}

return Result[R]{
StatusCode: res.StatusCode,
Headers: responseHeaders(res),
Data: data,
}, nil
}

func responseHeaders(res *http.Response) map[string]string {
headers := map[string]string{}
for key, val := range res.Header {
headers[key] = strings.Join(val, "; ")
}

return Result[R]{
StatusCode: res.StatusCode,
Headers: headers,
Data: data,
}, nil
return headers
}

func normalizeXMLPlistBody(body []byte) []byte {
Expand Down
13 changes: 13 additions & 0 deletions pkg/http/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,19 @@ var _ = Describe("Client", Ordered, func() {
})
})

It("preserves an XML redirect response and its location header", func() {
recorder := httptest.NewRecorder()
recorder.Header().Set("Location", "https://p7-buy.itunes.apple.com/authenticate")
recorder.WriteHeader(http.StatusFound)

sut := &client[xmlResult]{}
res, err := sut.handleXMLResponse(recorder.Result())

Expect(err).ToNot(HaveOccurred())
Expect(res.StatusCode).To(Equal(http.StatusFound))
Expect(res.Headers).To(HaveKeyWithValue("Location", "https://p7-buy.itunes.apple.com/authenticate"))
})

When("payload fails to decode", func() {
It("returns error", func() {
sut := NewClient[xmlResult](Args{
Expand Down
Loading