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
42 changes: 39 additions & 3 deletions pkg/appstore/appstore_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"time"
"unicode"

"github.com/majd/ipatool/v2/pkg/http"
"github.com/majd/ipatool/v2/pkg/util"
Expand Down Expand Up @@ -37,6 +38,11 @@ type LoginOutput struct {
}

func (t *appstore) Login(input LoginInput) (LoginOutput, error) {
authCode, err := normalizeAuthCode(input.AuthCode)
if err != nil {
return LoginOutput{}, err
}

macAddr, err := t.machine.MacAddress()
if err != nil {
return LoginOutput{}, fmt.Errorf("failed to get mac address: %w", err)
Expand Down Expand Up @@ -65,7 +71,7 @@ func (t *appstore) Login(input LoginInput) (LoginOutput, error) {
return LoginOutput{}, errors.New("SAP action signer factory returned nil")
}

acc, loginErr := t.login(input.Email, input.Password, input.AuthCode, guid, bag.SAPConfig.AuthEndpoint, signer)
acc, loginErr := t.login(input.Email, input.Password, authCode, guid, bag.SAPConfig.AuthEndpoint, signer)
closeErr := signer.Close()

if closeErr != nil {
Expand All @@ -88,6 +94,32 @@ func (t *appstore) Login(input LoginInput) (LoginOutput, error) {
return output, nil
}

func normalizeAuthCode(code string) (string, error) {
if code == "" {
return "", nil
}

// Terminals may wrap pasted input in bracketed-paste markers. Strip only
// a matched outer pair; other escape sequences are invalid input.
code = strings.TrimSpace(code)
if strings.HasPrefix(code, "\x1b[200~") && strings.HasSuffix(code, "\x1b[201~") {
code = strings.TrimSuffix(strings.TrimPrefix(code, "\x1b[200~"), "\x1b[201~")
}

code = strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return -1
}

return r
}, code)
if len(code) != 6 || strings.IndexFunc(code, func(r rune) bool { return r < '0' || r > '9' }) != -1 {
return "", errors.New("2FA code must contain exactly six digits")
}

return code, nil
}

type loginAddressResult struct {
FirstName string `plist:"firstName,omitempty"`
LastName string `plist:"lastName,omitempty"`
Expand Down Expand Up @@ -263,8 +295,12 @@ func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int
}
} else if attempt == 1 && res.Data.FailureType == FailureTypeInvalidCredentials {
retry = true
} else if res.Data.FailureType == "" && authCode == "" && res.Data.CustomerMessage == CustomerMessageBadLogin {
err = ErrAuthCodeRequired
} else if res.Data.FailureType == "" && res.Data.CustomerMessage == CustomerMessageBadLogin {
if authCode == "" {
err = ErrAuthCodeRequired
} else {
err = errors.New("apple did not complete verification; try a fresh 2FA code")
}
} else if res.Data.FailureType == "" && res.Data.CustomerMessage == CustomerMessageAccountDisabled {
err = NewErrorWithMetadata(errors.New("account is disabled"), res)
} else if res.Data.FailureType != "" {
Expand Down
44 changes: 44 additions & 0 deletions pkg/appstore/appstore_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ var _ = Describe("AppStore (Login)", func() {
ctrl.Finish()
})

DescribeTable("rejects malformed 2FA codes before preparing authentication", func(code string) {
_, err := as.Login(LoginInput{AuthCode: code})

Expect(err).To(MatchError("2FA code must contain exactly six digits"))
},
Entry("whitespace only", " \t\r\n"),
Entry("too short", "12345"),
Entry("too long", "1234567"),
Entry("letters", "12345a"),
Entry("non-ASCII digits", "123456"),
Entry("other escape sequences", "\x1b[31m123456"),
Entry("unmatched paste marker", "\x1b[200~123456"),
Entry("empty paste", "\x1b[200~\x1b[201~"),
Entry("embedded paste markers", "123\x1b[200~456\x1b[201~"),
)

Describe("transient authentication responses", func() {
It("retries the same request until it succeeds", func() {
request := as.loginRequest(testEmail, testPassword, "", "guid", testAuthEndpoint, 1, signer)
Expand Down Expand Up @@ -144,6 +160,26 @@ var _ = Describe("AppStore (Login)", func() {
}, nil)
})

DescribeTable("normalizes 2FA codes without changing the password", func(code, suffix string) {
const password = " \tpäss word\n"
mockClient.EXPECT().Send(gomock.Any()).DoAndReturn(func(req http.Request) (http.Result[loginResult], error) {
Expect(req.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("password", password+suffix))

return http.Result[loginResult]{}, errors.New("test complete")
})

_, err := as.Login(LoginInput{Password: password, AuthCode: code})

Expect(err).To(MatchError(ContainSubstring("test complete")))
},
Entry("no code on initial login", "", ""),
Entry("plain code with leading zero", "012345", "012345"),
Entry("spaces", "123 456", "123456"),
Entry("Unicode whitespace", "\t123\u00a0456\r\n", "123456"),
Entry("bracketed paste", "\x1b[200~123456\x1b[201~", "123456"),
Entry("bracketed paste with whitespace", " \x1b[200~123 456\n\x1b[201~\r\n", "123456"),
)

When("client returns error", func() {
var clientErr error

Expand Down Expand Up @@ -259,6 +295,7 @@ var _ = Describe("AppStore (Login)", func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[loginResult]{
StatusCode: 200,
Data: loginResult{
FailureType: "",
CustomerMessage: CustomerMessageBadLogin,
Expand All @@ -272,6 +309,13 @@ var _ = Describe("AppStore (Login)", func() {
})
Expect(err).To(Equal(ErrAuthCodeRequired))
})

It("reports an incomplete verification when a code was already supplied", func() {
_, err := as.Login(LoginInput{Password: testPassword, AuthCode: "123456"})

Expect(err).To(MatchError("apple did not complete verification; try a fresh 2FA code"))
Expect(errors.Is(err, ErrAuthCodeRequired)).To(BeFalse())
})
})

When("store API redirects", func() {
Expand Down
Loading