Skip to content

Commit

Permalink
Fix authentication (#316)
Browse files Browse the repository at this point in the history
login: increment 'attempt' while following redirects

Apple has introduced some dynamic GET parameters into their
redirects, forcing us to use the main domain ( no `p71-`
and such prefixes ) to obtain those parameters.

However, when following such redirects, we shall also increment
the `attempt` parameter (something that was hard-coded before).
  • Loading branch information
tux-mind authored Dec 7, 2024
1 parent 63ee6fc commit 8a23d6f
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 47 deletions.
101 changes: 59 additions & 42 deletions pkg/appstore/appstore_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"

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

var (
Expand All @@ -31,7 +33,7 @@ func (t *appstore) Login(input LoginInput) (LoginOutput, error) {

guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "")

acc, err := t.login(input.Email, input.Password, input.AuthCode, guid, 0)
acc, err := t.login(input.Email, input.Password, input.AuthCode, guid)
if err != nil {
return LoginOutput{}, err
}
Expand Down Expand Up @@ -59,28 +61,36 @@ type loginResult struct {
PasswordToken string `plist:"passwordToken,omitempty"`
}

func (t *appstore) login(email, password, authCode, guid string, attempt int) (Account, error) {
request := t.loginRequest(email, password, authCode, guid)
res, err := t.loginClient.Send(request)

if err != nil {
return Account{}, fmt.Errorf("request failed: %w", err)
}

if attempt == 0 && res.Data.FailureType == FailureTypeInvalidCredentials {
return t.login(email, password, authCode, guid, 1)
}

if res.Data.FailureType != "" && res.Data.CustomerMessage != "" {
return Account{}, NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res)
func (t *appstore) login(email, password, authCode, guid string) (Account, error) {
redirect := ""
var err error
retry := true
var res http.Result[loginResult]

for attempt := 1; retry && attempt <= 4; attempt++ {
ac := authCode
if attempt == 1 {
ac = ""
}
request := t.loginRequest(email, password, ac, guid, attempt)
request.URL, redirect = util.IfEmpty(redirect, request.URL), ""
res, err = t.loginClient.Send(request)
if err != nil {
return Account{}, fmt.Errorf("request failed: %w", err)
}

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

if res.Data.FailureType != "" {
return Account{}, NewErrorWithMetadata(errors.New("something went wrong"), res)
if retry {
return Account{}, NewErrorWithMetadata(errors.New("too many attempts"), res)
}

if res.Data.FailureType == "" && authCode == "" && res.Data.CustomerMessage == CustomerMessageBadLogin {
return Account{}, ErrAuthCodeRequired
sf, err := res.GetHeader(HTTPHeaderStoreFront)
if err != nil {
return Account{}, NewErrorWithMetadata(fmt.Errorf("failed to get storefront header: %w", err), res)
}

addr := res.Data.Account.Address
Expand All @@ -89,7 +99,7 @@ func (t *appstore) login(email, password, authCode, guid string, attempt int) (A
Email: res.Data.Account.Email,
PasswordToken: res.Data.PasswordToken,
DirectoryServicesID: res.Data.DirectoryServicesID,
StoreFront: res.Headers[HTTPHeaderStoreFront],
StoreFront: sf,
Password: password,
}

Expand All @@ -106,39 +116,46 @@ func (t *appstore) login(email, password, authCode, guid string, attempt int) (A
return acc, nil
}

func (t *appstore) loginRequest(email, password, authCode, guid string) http.Request {
attempt := "4"
if authCode != "" {
attempt = "2"
func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode string) (retry bool, redirect string, err error) {
if res.StatusCode == 302 {
if redirect, err = res.GetHeader("location"); err != nil {
err = fmt.Errorf("failed to retrieve redirect location: %w", err)
} else {
retry = true
}
} 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 != "" {
if res.Data.CustomerMessage != "" {
err = NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res)
} else {
err = NewErrorWithMetadata(errors.New("something went wrong"), res)
}
} else if res.StatusCode != 200 || res.Data.PasswordToken == "" || res.Data.DirectoryServicesID == "" {
err = NewErrorWithMetadata(errors.New("something went wrong"), res)
}
return
}

func (t *appstore) loginRequest(email, password, authCode, guid string, attempt int) http.Request {
return http.Request{
Method: http.MethodPOST,
URL: t.authDomain(authCode, guid),
URL: fmt.Sprintf("https://%s%s", PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathAuthenticate),
ResponseFormat: http.ResponseFormatXML,
Headers: map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
},
Payload: &http.XMLPayload{
Content: map[string]interface{}{
"appleId": email,
"attempt": attempt,
"createSession": "true",
"guid": guid,
"password": fmt.Sprintf("%s%s", password, authCode),
"rmp": "0",
"why": "signIn",
"appleId": email,
"attempt": strconv.Itoa(attempt),
"guid": guid,
"password": fmt.Sprintf("%s%s", password, authCode),
"rmp": "0",
"why": "signIn",
},
},
}
}

func (*appstore) authDomain(authCode, guid string) string {
prefix := PrivateAppStoreAPIDomainPrefixWithoutAuthCode
if authCode != "" {
prefix = PrivateAppStoreAPIDomainPrefixWithAuthCode
}

return fmt.Sprintf(
"https://%s-%s%s?guid=%s", prefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathAuthenticate, guid)
}
64 changes: 62 additions & 2 deletions pkg/appstore/appstore_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,24 +135,82 @@ var _ = Describe("AppStore (Login)", func() {
}, nil)
})

It("returns error", func() {
It("returns ErrAuthCodeRequired error", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).To(HaveOccurred())
Expect(err).To(Equal(ErrAuthCodeRequired))
})
})

When("store API redirects", func() {
const (
testRedirectLocation = "https://" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathAuthenticate + "?PRH=31&Pod=31"
)

BeforeEach(func() {
firstCall := mockClient.EXPECT().
Send(gomock.Any()).
Do(func(req http.Request) {
Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{}))
x := req.Payload.(*http.XMLPayload)
Expect(x.Content).To(HaveKeyWithValue("attempt", "1"))
}).
Return(http.Result[loginResult]{
StatusCode: 302,
Headers: map[string]string{"Location": testRedirectLocation},
}, nil)
secondCall := mockClient.EXPECT().
Send(gomock.Any()).
Do(func(req http.Request) {
Expect(req.URL).To(Equal(testRedirectLocation))
Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{}))
x := req.Payload.(*http.XMLPayload)
Expect(x.Content).To(HaveKeyWithValue("attempt", "2"))
}).
Return(http.Result[loginResult]{}, errors.New("test complete"))
gomock.InOrder(firstCall, secondCall)
})

It("follows the redirect and increments attempt", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).To(MatchError("request failed: test complete"))
})
})

When("store API redirects too much", func() {
BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[loginResult]{
StatusCode: 302,
Headers: map[string]string{"Location": "hello"},
}, nil).
Times(4)
})
It("bails out", func() {
_, err := as.Login(LoginInput{
Password: testPassword,
})
Expect(err).To(MatchError("too many attempts"))
})
})

When("store API returns valid response", func() {
const (
testPasswordToken = "test-password-token"
testDirectoryServicesID = "directory-services-id"
testStoreFront = "test-storefront"
)

BeforeEach(func() {
mockClient.EXPECT().
Send(gomock.Any()).
Return(http.Result[loginResult]{
StatusCode: 200,
Headers: map[string]string{HTTPHeaderStoreFront: testStoreFront},
Data: loginResult{
PasswordToken: testPasswordToken,
DirectoryServicesID: testDirectoryServicesID,
Expand All @@ -178,6 +236,7 @@ var _ = Describe("AppStore (Login)", func() {
PasswordToken: testPasswordToken,
Password: testPassword,
DirectoryServicesID: testDirectoryServicesID,
StoreFront: testStoreFront,
}

var got Account
Expand Down Expand Up @@ -207,6 +266,7 @@ var _ = Describe("AppStore (Login)", func() {
PasswordToken: testPasswordToken,
Password: testPassword,
DirectoryServicesID: testDirectoryServicesID,
StoreFront: testStoreFront,
}

var got Account
Expand Down
14 changes: 12 additions & 2 deletions pkg/http/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import (
"howett.net/plist"
)

const (
appStoreAuthURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate"
)

//go:generate go run go.uber.org/mock/mockgen -source=client.go -destination=client_mock.go -package=http
type Client[R interface{}] interface {
Send(request Request) (Result[R], error)
Expand Down Expand Up @@ -47,8 +51,14 @@ func (t *AddHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error
func NewClient[R interface{}](args Args) Client[R] {
return &client[R]{
internalClient: http.Client{
Timeout: 0,
Jar: args.CookieJar,
Timeout: 0,
Jar: args.CookieJar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if req.Referer() == appStoreAuthURL {
return http.ErrUseLastResponse
}
return nil
},
Transport: &AddHeaderTransport{http.DefaultTransport},
},
cookieJar: args.CookieJar,
Expand Down
2 changes: 1 addition & 1 deletion pkg/http/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ const (
)

const (
DefaultUserAgent = "Configurator/2.15 (Macintosh; OS X 11.0.0; 16G29) AppleWebKit/2603.3.8"
DefaultUserAgent = "Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) AppleWebKit/0620.1.16.11.6"
)
19 changes: 19 additions & 0 deletions pkg/http/result.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
package http

import (
"errors"
"strings"
)

var (
ErrHeaderNotFound = errors.New("header not found")
)

type Result[R interface{}] struct {
StatusCode int
Headers map[string]string
Data R
}

func (c *Result[R]) GetHeader(key string) (string, error) {
key = strings.ToLower(key)
for k, v := range c.Headers {
if strings.ToLower(k) == key {
return v, nil
}
}
return "", ErrHeaderNotFound
}

0 comments on commit 8a23d6f

Please sign in to comment.