diff --git a/pkg/appstore/appstore_login.go b/pkg/appstore/appstore_login.go index 6025059c..c89913ca 100644 --- a/pkg/appstore/appstore_login.go +++ b/pkg/appstore/appstore_login.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "time" + "unicode" "github.com/majd/ipatool/v2/pkg/http" "github.com/majd/ipatool/v2/pkg/util" @@ -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) @@ -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 { @@ -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"` @@ -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 != "" { diff --git a/pkg/appstore/appstore_login_test.go b/pkg/appstore/appstore_login_test.go index 035b6a13..40b83933 100644 --- a/pkg/appstore/appstore_login_test.go +++ b/pkg/appstore/appstore_login_test.go @@ -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) @@ -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 @@ -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, @@ -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() {