diff --git a/cmd/auth.go b/cmd/auth.go index 800f04f8..7099ab0b 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -33,13 +33,13 @@ func loginCmd() *cobra.Command { interactive := cmd.Context().Value(interactiveKey).(bool) if email == "" && !interactive { - return errors.New("email is required when not running in interactive mode; use the \"--email\" flag") + return errors.New("an Apple ID is required when not running in interactive mode; use the \"--email\" flag") } if email == "" && interactive { - value, err := readPrompt("enter email: ", false) + value, err := readPrompt("enter Apple ID (email address or phone number): ", false) if err != nil { - return fmt.Errorf("failed to read email: %w", err) + return fmt.Errorf("failed to read Apple ID: %w", err) } email = value } @@ -111,7 +111,7 @@ func loginCmd() *cobra.Command { }, } - cmd.Flags().StringVarP(&email, "email", "e", "", "email address for the Apple ID (required)") + cmd.Flags().StringVarP(&email, "email", "e", "", "Apple ID email address or phone number (required)") cmd.Flags().StringVarP(&password, "password", "p", "", "password for the Apple ID (required)") cmd.Flags().StringVar(&authCode, "auth-code", "", "2FA code for the Apple ID") diff --git a/cmd/auth_test.go b/cmd/auth_test.go index b3fbbf0d..3a56c4dc 100644 --- a/cmd/auth_test.go +++ b/cmd/auth_test.go @@ -30,24 +30,28 @@ var _ = Describe("Login command", func() { Expect(cmd.Execute()).To(MatchError(message)) Expect(store.loginCalls).To(BeZero()) }, - Entry("missing email", []string{"--password", "secret"}, "email is required when not running in interactive mode; use the \"--email\" flag"), + Entry("missing Apple ID", []string{"--password", "secret"}, "an Apple ID is required when not running in interactive mode; use the \"--email\" flag"), Entry("missing password", []string{"--email", "user@example.com"}, "password is required when not running in interactive mode; use the \"--password\" flag"), ) - DescribeTable("uses supplied credentials", func(interactive bool) { + DescribeTable("uses supplied credentials", func(interactive bool, appleID string) { cmd := loginCmd() cmd.SetContext(context.WithValue(context.Background(), interactiveKey, interactive)) - cmd.SetArgs([]string{"--email", "user@example.com", "--password", "secret"}) + cmd.SetArgs([]string{"--email", appleID, "--password", "secret"}) Expect(cmd.Execute()).To(Succeed()) Expect(store.loginCalls).To(Equal(1)) - Expect(store.input).To(Equal(appstore.LoginInput{Email: "user@example.com", Password: "secret"})) + Expect(store.input).To(Equal(appstore.LoginInput{Email: appleID, Password: "secret"})) }, - Entry("interactive", true), - Entry("non-interactive", false), + Entry("interactive email", true, "user@example.com"), + Entry("non-interactive email", false, "user@example.com"), + Entry("interactive Chinese phone number", true, "+8613912345678"), + Entry("non-interactive Chinese phone number", false, "+8613912345678"), + Entry("interactive Indian phone number", true, "9123456789"), + Entry("non-interactive Indian phone number", false, "9123456789"), ) - DescribeTable("prompts for email", func(input, message string) { + DescribeTable("prompts for an Apple ID", func(input, appleID, message string) { dir := GinkgoT().TempDir() inputPath := filepath.Join(dir, "stdin") Expect(os.WriteFile(inputPath, []byte(input), 0o600)).To(Succeed()) @@ -72,7 +76,7 @@ var _ = Describe("Login command", func() { if message == "" { Expect(err).NotTo(HaveOccurred()) Expect(store.loginCalls).To(Equal(1)) - Expect(store.input).To(Equal(appstore.LoginInput{Email: "user@example.com", Password: "secret"})) + Expect(store.input).To(Equal(appstore.LoginInput{Email: appleID, Password: "secret"})) } else { Expect(err).To(MatchError(message)) Expect(store.loginCalls).To(BeZero()) @@ -80,10 +84,11 @@ var _ = Describe("Login command", func() { prompt, err := os.ReadFile(stderr.Name()) Expect(err).NotTo(HaveOccurred()) - Expect(string(prompt)).To(Equal("enter email: ")) + Expect(string(prompt)).To(Equal("enter Apple ID (email address or phone number): ")) }, - Entry("reads unmasked input without requiring a terminal", "user@example.com\n", ""), - Entry("reports input errors", "", "failed to read email: failed to read input: EOF"), + Entry("reads email without requiring a terminal", "user@example.com\n", "user@example.com", ""), + Entry("reads a phone number without requiring a terminal", "+91 91234-56789\n", "+91 91234-56789", ""), + Entry("reports input errors", "", "", "failed to read Apple ID: failed to read input: EOF"), ) }) diff --git a/pkg/appstore/appstore_login.go b/pkg/appstore/appstore_login.go index 6ac88727..b462ffd5 100644 --- a/pkg/appstore/appstore_login.go +++ b/pkg/appstore/appstore_login.go @@ -409,7 +409,7 @@ func authenticationRedirectError(err error, status int, location string) error { } func (t *appstore) loginRequest(email, password, authCode, guid, endpoint string, attempt int, signer ActionSigner) http.Request { - return http.Request{ + request := http.Request{ Method: http.MethodPOST, URL: endpoint, ResponseFormat: http.ResponseFormatXML, @@ -428,6 +428,11 @@ func (t *appstore) loginRequest(email, password, authCode, guid, endpoint string }, }, } + if storefront := phoneNumberStorefront(email); storefront != "" { + request.Headers["X-Apple-Store-Front"] = storefront + } + + return request } func authenticationRequestError(err error) error { diff --git a/pkg/appstore/appstore_login_storefront.go b/pkg/appstore/appstore_login_storefront.go new file mode 100644 index 00000000..1936f6b7 --- /dev/null +++ b/pkg/appstore/appstore_login_storefront.go @@ -0,0 +1,54 @@ +package appstore + +import "strings" + +// phoneNumberStorefront supplies a routing hint for phone-based Apple Accounts +// in mainland China and India. It does not change the Apple ID sent to Apple. +func phoneNumberStorefront(appleID string) string { + number := strings.ReplaceAll(strings.TrimSpace(appleID), " ", "") + number = strings.ReplaceAll(number, "-", "") + + international := false + + switch { + case strings.HasPrefix(number, "+"): + international = true + number = number[1:] + case strings.HasPrefix(number, "00"): + international = true + number = number[2:] + } + + for _, digit := range number { + if digit < '0' || digit > '9' { + return "" + } + } + + // Check the full length before removing a country code: a local Indian + // number can itself start with 91. Explicit international numbers must + // match a supported country code and cannot fall back to a local format. + switch { + case len(number) == 13 && strings.HasPrefix(number, "861"): + return storeFronts["CN"] + case len(number) == 12 && strings.HasPrefix(number, "91"): + number = number[2:] + case international: + return "" + } + + if len(number) == 11 && number[0] == '1' { + return storeFronts["CN"] + } + + // India also permits a trunk prefix when writing a national number. + if !international && len(number) == 11 && number[0] == '0' { + number = number[1:] + } + + if len(number) == 10 && number[0] >= '6' && number[0] <= '9' { + return storeFronts["IN"] + } + + return "" +} diff --git a/pkg/appstore/appstore_login_storefront_test.go b/pkg/appstore/appstore_login_storefront_test.go new file mode 100644 index 00000000..6d90668f --- /dev/null +++ b/pkg/appstore/appstore_login_storefront_test.go @@ -0,0 +1,61 @@ +package appstore + +import ( + "github.com/majd/ipatool/v2/pkg/http" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Phone account login routing", func() { + DescribeTable("sets a storefront hint without rewriting the Apple ID", func(appleID, storefront string) { + request := (&appstore{}).loginRequest(appleID, "password", "", "guid", testAuthEndpoint, 1, &stubActionSigner{}) + + Expect(request.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("appleId", appleID)) + if storefront == "" { + Expect(request.Headers).NotTo(HaveKey("X-Apple-Store-Front")) + } else { + Expect(request.Headers).To(HaveKeyWithValue("X-Apple-Store-Front", storefront)) + } + }, + Entry("Chinese national number", "13912345678", "143465"), + Entry("Chinese number with a plus prefix", "+8613912345678", "143465"), + Entry("Chinese number with a 00 prefix", "008613912345678", "143465"), + Entry("Chinese number with a bare country code", "8613912345678", "143465"), + Entry("Chinese number with spaces and hyphens", " +86 139-1234-5678 ", "143465"), + Entry("Indian national number beginning with 91", "9123456789", "143467"), + Entry("Indian national number beginning with 86", "8612345678", "143467"), + Entry("Indian national number beginning with 6", "6123456789", "143467"), + Entry("Indian number with a trunk prefix", "09123456789", "143467"), + Entry("Indian number with a plus prefix", "+919123456789", "143467"), + Entry("Indian number with a 00 prefix", "00919123456789", "143467"), + Entry("Indian number with a bare country code", "919123456789", "143467"), + Entry("Indian number with spaces and hyphens", "+91 91234-56789", "143467"), + Entry("email", "person@example.test", ""), + Entry("numeric email", "13912345678@example.test", ""), + Entry("legacy username", "some-account", ""), + Entry("empty input", "", ""), + Entry("formatting only", " -- ", ""), + Entry("plus only", "+", ""), + Entry("international access code only", "00", ""), + Entry("letters within a number", "139x12345678", ""), + Entry("unsupported punctuation", "139/1234/5678", ""), + Entry("non-ASCII digits", "+8613912345678", ""), + Entry("non-ASCII plus", "+8613912345678", ""), + Entry("embedded control character", "139\n12345678", ""), + Entry("misplaced plus", "139+12345678", ""), + Entry("repeated plus", "++8613912345678", ""), + Entry("mixed international prefixes", "+008613912345678", ""), + Entry("unsupported country with a Chinese-looking local format", "+13912345678", ""), + Entry("unsupported country with a 00 prefix", "0013912345678", ""), + Entry("unsupported country with an Indian-looking local format", "+8612345678", ""), + Entry("explicit country code missing national digits", "+9123456789", ""), + Entry("short Chinese number", "+861391234567", ""), + Entry("long Chinese number", "+86139123456789", ""), + Entry("Chinese country code with an Indian trunk number", "8609123456789", ""), + Entry("invalid Chinese national prefix", "+8623912345678", ""), + Entry("short Indian number", "+91912345678", ""), + Entry("long Indian number", "+9191234567890", ""), + Entry("invalid Indian national prefix", "+915123456789", ""), + Entry("trunk prefix after an international code", "+9109123456789", ""), + ) +}) diff --git a/pkg/appstore/appstore_login_test.go b/pkg/appstore/appstore_login_test.go index 35c8d8d7..0a96c6c6 100644 --- a/pkg/appstore/appstore_login_test.go +++ b/pkg/appstore/appstore_login_test.go @@ -77,8 +77,8 @@ var _ = Describe("AppStore (Login)", func() { ) Describe("transient authentication responses", func() { - It("retries the same request until it succeeds", func() { - request := as.loginRequest(testEmail, testPassword, "", "guid", testAuthEndpoint, 1, signer) + DescribeTable("retries the same request until it succeeds", func(appleID, storefront string) { + request := as.loginRequest(appleID, testPassword, "", "guid", testAuthEndpoint, 1, signer) responses := []struct { result http.Result[loginResult] err error @@ -92,6 +92,8 @@ var _ = Describe("AppStore (Login)", func() { Send(gomock.Any()). DoAndReturn(func(actual http.Request) (http.Result[loginResult], error) { Expect(actual.URL).To(Equal(request.URL)) + Expect(actual.Headers["X-Apple-Store-Front"]).To(Equal(storefront)) + Expect(actual.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("appleId", appleID)) Expect(actual.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("attempt", "1")) response := responses[call] call++ @@ -104,7 +106,11 @@ var _ = Describe("AppStore (Login)", func() { Expect(err).ToNot(HaveOccurred()) Expect(result.StatusCode).To(Equal(200)) - }) + }, + Entry("email account", testEmail, ""), + Entry("Chinese phone account", "+8613912345678", "143465"), + Entry("Indian phone account", "9123456789", "143467"), + ) DescribeTable("bounds retries to transient statuses", func(status, expectedCalls int) { diff --git a/pkg/appstore/authentication_redirect_test.go b/pkg/appstore/authentication_redirect_test.go index ac6feb13..84f80504 100644 --- a/pkg/appstore/authentication_redirect_test.go +++ b/pkg/appstore/authentication_redirect_test.go @@ -20,8 +20,8 @@ import ( ) var _ = Describe("Authentication redirects", func() { - DescribeTable("preserves a signed POST, cookies and 2FA after a credential retry", - func(status int, suffix string) { + DescribeTable("preserves a signed POST, cookies, credentials and storefront after a credential retry", + func(status int, suffix, appleID, storefront, authCode string) { ctrl := gomock.NewController(GinkgoT()) jar, err := cookiejar.New(nil) Expect(err).NotTo(HaveOccurred()) @@ -37,6 +37,7 @@ var _ = Describe("Authentication redirects", func() { defer GinkgoRecover() calls++ Expect(r.Method).To(Equal(http.MethodPost)) + Expect(r.Header.Get("X-Apple-Store-Front")).To(Equal(storefront)) body, err := io.ReadAll(r.Body) Expect(err).NotTo(HaveOccurred()) signature, err := base64.StdEncoding.DecodeString(r.Header.Get(apphttp.HeaderAppleActionSignature)) @@ -45,7 +46,8 @@ var _ = Describe("Authentication redirects", func() { var payload map[string]string _, err = plist.Unmarshal(body, &payload) Expect(err).NotTo(HaveOccurred()) - Expect(payload["password"]).To(Equal("password123456")) + Expect(payload["appleId"]).To(Equal(appleID)) + Expect(payload["password"]).To(Equal("password" + authCode)) if calls > 1 { cookie, err := r.Cookie("session") Expect(err).NotTo(HaveOccurred()) @@ -84,20 +86,23 @@ var _ = Describe("Authentication redirects", func() { return client.Send(request) }).Times(3) signer := &stubActionSigner{} - account, err := sut.login("email", "password", "123456", "guid", testAuthEndpoint+suffix, signer) + account, err := sut.login(appleID, "password", authCode, "guid", testAuthEndpoint+suffix, signer) Expect(err).NotTo(HaveOccurred()) Expect(account.PasswordToken).To(Equal("token")) + Expect(account.StoreFront).To(Equal("143441-1,29")) Expect(destinations).To(Equal([]string{testAuthEndpoint + suffix, testAuthEndpoint + suffix, podURL})) Expect(signer.signCalls).To(Equal(3)) }, - Entry("301, bare path", 301, ""), - Entry("301, trailing slash", 301, "/"), - Entry("302, bare path", 302, ""), - Entry("302, trailing slash", 302, "/"), - Entry("307, bare path", 307, ""), - Entry("307, trailing slash", 307, "/"), - Entry("308, bare path", 308, ""), - Entry("308, trailing slash", 308, "/"), + Entry("301, bare path", 301, "", "user@example.test", "", "123456"), + Entry("301, trailing slash", 301, "/", "user@example.test", "", "123456"), + Entry("302, bare path", 302, "", "user@example.test", "", "123456"), + Entry("302, trailing slash", 302, "/", "user@example.test", "", "123456"), + Entry("307, bare path", 307, "", "user@example.test", "", "123456"), + Entry("307, trailing slash", 307, "/", "user@example.test", "", "123456"), + Entry("308, bare path", 308, "", "user@example.test", "", "123456"), + Entry("308, trailing slash", 308, "/", "user@example.test", "", "123456"), + Entry("Chinese phone number before 2FA", 302, "/", "+86 139-1234-5678", "143465", ""), + Entry("Indian phone number with 2FA", 307, "/", "9123456789", "143467", "012345"), ) It("follows relative and pod redirects within the redirect budget", func() {