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
8 changes: 4 additions & 4 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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")

Expand Down
27 changes: 16 additions & 11 deletions cmd/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -72,18 +76,19 @@ 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())
}

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"),
)
})

Expand Down
7 changes: 6 additions & 1 deletion pkg/appstore/appstore_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions pkg/appstore/appstore_login_storefront.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
61 changes: 61 additions & 0 deletions pkg/appstore/appstore_login_storefront_test.go
Original file line number Diff line number Diff line change
@@ -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", ""),
)
})
12 changes: 9 additions & 3 deletions pkg/appstore/appstore_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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++
Expand All @@ -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) {
Expand Down
29 changes: 17 additions & 12 deletions pkg/appstore/authentication_redirect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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))
Expand All @@ -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())
Expand Down Expand Up @@ -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() {
Expand Down
Loading