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
14 changes: 8 additions & 6 deletions pkg/appstore/appstore_bag.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package appstore

import (
"errors"
"fmt"
gohttp "net/http"
"net/url"
Expand Down Expand Up @@ -111,18 +112,19 @@ func validateSAPConfig(config SAPConfig) error {
}

func validateAuthenticationEndpoint(endpoint string) error {
parsed, err := url.ParseRequestURI(endpoint)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
return fmt.Errorf("invalid authentication endpoint %q", endpoint)
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil ||
parsed.Fragment != "" || parsed.RawPath != "" || (parsed.Port() != "" && parsed.Port() != "443") {
return errors.New("invalid authentication endpoint")
}

host := strings.ToLower(parsed.Hostname())
if host != PrivateAppStoreAPIDomain && !strings.HasSuffix(host, "-buy.itunes.apple.com") {
return fmt.Errorf("unsupported authentication endpoint %q", endpoint)
return errors.New("unsupported authentication endpoint")
}

if parsed.Path != PrivateAppStoreAPIPathAuth {
return fmt.Errorf("unsupported authentication endpoint %q", endpoint)
if parsed.Path != PrivateAppStoreAPIPathAuth && parsed.Path != PrivateAppStoreAPIPathAuth+"/" {
return errors.New("unsupported authentication endpoint")
}

return nil
Expand Down
15 changes: 15 additions & 0 deletions pkg/appstore/appstore_bag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ var _ = Describe("AppStore (Bag)", func() {
ctrl.Finish()
})

DescribeTable("preserves supported authentication URLs from the bag", func(endpoint string) {
mockMachine.EXPECT().MacAddress().Return("00:11:22:33:44:55", nil)
result := validBagResult()
result.URLBag.AuthEndpoint = endpoint
mockBagClient.EXPECT().Send(gomock.Any()).Return(http.Result[bagResult]{StatusCode: gohttp.StatusOK, Data: result}, nil)
out, err := as.Bag(BagInput{})
Expect(err).NotTo(HaveOccurred())
Expect(out.AuthEndpoint).To(Equal(endpoint))
Expect(out.SAPConfig.AuthEndpoint).To(Equal(endpoint))
},
Entry("bare path", testAuthEndpoint),
Entry("trailing slash", testAuthEndpoint+"/"),
Entry("pod URL with routing query", "https://p7-buy.itunes.apple.com"+PrivateAppStoreAPIPathAuth+"/?routing=opaque"),
)

When("fails to read machine MAC address", func() {
BeforeEach(func() {
mockMachine.EXPECT().
Expand Down
97 changes: 73 additions & 24 deletions pkg/appstore/appstore_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import (
"errors"
"fmt"
gohttp "net/http"
"net/url"
"strconv"
"strings"
"time"
"unicode"

"github.com/majd/ipatool/v2/pkg/http"
"github.com/majd/ipatool/v2/pkg/util"
)

var (
Expand All @@ -20,6 +20,7 @@ var (

const (
maxAuthenticationRequestAttempts = 3
maxAuthenticationRedirects = 4
authenticationRetryDelay = 10 * time.Second
maxAuthenticationRetryDelay = 30 * time.Second
)
Expand Down Expand Up @@ -139,25 +140,15 @@ type loginResult struct {
}

func (t *appstore) login(email, password, authCode, guid, endpoint string, signer ActionSigner) (Account, error) {
redirect := ""
request := t.loginRequest(email, password, authCode, guid, endpoint, 1, signer)
redirects := 0

var (
err error
res http.Result[loginResult]
)

retry := true

for attempt := 1; retry && attempt <= 4; 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, signer)
request.URL, _ = util.IfEmpty(redirect, request.URL), ""
for attempt := 1; ; {
res, err = t.sendAuthenticationRequest(request)

if err != nil {
Expand All @@ -166,20 +157,37 @@ func (t *appstore) login(email, password, authCode, guid, endpoint string, signe
stage = "2FA verification"
}

if redirect != "" {
if redirects > 0 {
stage += " at Store pod"
}

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

if retry, redirect, err = t.parseLoginResponse(&res, attempt, authCode); err != nil {
retry, redirect, err := t.parseLoginResponse(&res, attempt, authCode, request.URL)
if err != nil {
return Account{}, err
}
}

if retry {
return Account{}, NewErrorWithMetadata(errors.New("too many attempts"), res)
if !retry {
break
}

if redirect != "" {
if redirects == maxAuthenticationRedirects {
return Account{}, authenticationRedirectError(errors.New("too many authentication redirects"), res.StatusCode, redirect)
}

redirects++
// Only the destination changes: redirects belong to the same login
// attempt and must retain its payload, including the attempt value.
request.URL = redirect

continue
}

attempt++
request = t.loginRequest(email, password, authCode, guid, request.URL, attempt, signer)
}

sf, err := res.GetHeader(HTTPHeaderStoreFront)
Expand Down Expand Up @@ -278,21 +286,29 @@ func retryableAuthenticationError(err error) (int, bool) {
return status, retry
}

func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode string) (bool, string, error) {
func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode, endpoint string) (bool, string, error) {
var (
retry bool
redirect string
err error
)

if res.StatusCode == gohttp.StatusFound {
if res.StatusCode >= gohttp.StatusMultipleChoices && res.StatusCode < gohttp.StatusBadRequest {
if redirect, err = res.GetHeader("location"); err != nil {
err = fmt.Errorf("failed to retrieve redirect location: %w", err)
} else if err = validateAuthenticationEndpoint(redirect); err != nil {
err = fmt.Errorf("invalid authentication redirect: %w", err)
} else if !http.IsAuthenticationRedirect(res.StatusCode) {
err = errors.New("unsupported authentication redirect status")
} else {
retry = true
redirect, err = resolveAuthenticationRedirect(endpoint, redirect)
}

if err != nil {
location, _ := res.GetHeader("location")

return false, "", authenticationRedirectError(err, res.StatusCode, location)
}

retry = true
} else if attempt == 1 && res.Data.FailureType == FailureTypeInvalidCredentials {
retry = true
} else if res.Data.FailureType == "" && res.Data.CustomerMessage == CustomerMessageBadLogin {
Expand All @@ -316,6 +332,39 @@ func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int
return retry, redirect, err
}

func resolveAuthenticationRedirect(endpoint, location string) (string, error) {
base, err := url.Parse(endpoint)
if err != nil {
return "", errors.New("invalid authentication redirect base URL")
}

reference, err := url.Parse(strings.TrimSpace(location))
if err != nil || strings.TrimSpace(location) == "" {
return "", errors.New("invalid authentication redirect location")
}

destination := base.ResolveReference(reference).String()
if err := validateAuthenticationEndpoint(destination); err != nil {
return "", fmt.Errorf("invalid authentication redirect: %w", err)
}

return destination, nil
}

func authenticationRedirectError(err error, status int, location string) error {
// Keep useful routing diagnostics without retaining URL credentials, query
// parameters, fragments, response bodies or arbitrary response headers.
destination := "invalid URL"
if parsed, parseErr := url.Parse(location); parseErr == nil {
destination = (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host, Path: parsed.Path}).String()
}

return NewErrorWithMetadata(fmt.Errorf("%w (HTTP %d)", err, status), map[string]interface{}{
"statusCode": status,
"destination": destination,
})
}

func (t *appstore) loginRequest(email, password, authCode, guid, endpoint string, attempt int, signer ActionSigner) http.Request {
return http.Request{
Method: http.MethodPOST,
Expand Down
5 changes: 4 additions & 1 deletion pkg/appstore/appstore_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,9 +453,12 @@ var _ = Describe("AppStore (Login)", func() {
type stubActionSigner struct {
closeCalls int
closeErr error
signCalls int
}

func (*stubActionSigner) Sign(data []byte) ([]byte, error) {
func (s *stubActionSigner) Sign(data []byte) ([]byte, error) {
s.signCalls++

return append([]byte(nil), data...), nil
}

Expand Down
Loading
Loading