awa / go-iap

go-iap verifies the purchase receipt via AppStore, GooglePlayStore, AmazonAppStore and Huawei HMS.
MIT License
875 stars 246 forks source link

appstore api: return status code 401 #277

Closed ganlvtech closed 4 months ago

ganlvtech commented 4 months ago

When my server is 1 second faster than Apple's server, it returns 401.

https://developer.apple.com/documentation/appstoreserverapi/generating_json_web_tokens_for_api_requests

https://github.com/awa/go-iap/blob/14fdbb2d2b3873e9939e56b741f26e233034334d/appstore/api/token.go#L75-L93

We can make issuedAt be the time several seconds ago.

now := time.Now().Unix() // prevent 2nd time.Now() returns 1 second bigger than the first one. and more than 60 minutes
issuedAt := now - 10 // prevent "token used before issued" and prevent expiredAt > now + 60 minutes
expiredAt := issuedAt + 3600
ganlvtech commented 4 months ago

My temporary hack for this bug:

a := api.NewStoreClient(c)

// region fix token bug
_ = a.Token.Generate()
claims := jwt.MapClaims{}
token, _, _ := jwt.NewParser().ParseUnverified(a.Token.Bearer, claims)
issuedAt := time.Now().Unix() - 10
expiredAt := issuedAt + 3600
claims["iat"] = issuedAt
claims["exp"] = expiredAt
a.Token.Bearer, _ = token.SignedString(a.Token.AuthKey)
a.Token.ExpiredAt = expiredAt
// endregion fix token bug

response, err := a.GetTransactionInfo(ctx, transactionId)
richzw commented 4 months ago

@ganlvtech

I am confused that why my server is 1 second faster than Apple's server? Does the api return 200 when the server time corresponding with Apple server time?

Furthermore, I try to change the iat faster than server time with 100 seconds, the api return 200 in my test environment.

    issuedAt := time.Now().Add(time.Duration(100) * time.Second).Unix()
    expiredAt := time.Now().Add(time.Duration(1) * time.Hour).Unix()

Maybe the issuedAt and expiredAt of the struct Token could be exposed, so that user could set them.

ganlvtech commented 4 months ago

@richzw if time.Now() is 100 seconds faster. the expiredAt will be 60 * 60 + 100 seconds faster. You can try

expiredAt := time.Now().Add(time.Duration(1) * time.Hour + 100 * time.Second).Unix()
richzw commented 4 months ago

@richzw if time.Now() is 100 seconds faster. the expiredAt will be 60 * 60 + 100 seconds faster. You can try

expiredAt := time.Now().Add(time.Duration(1) * time.Hour + 100 * time.Second).Unix()

@ganlvtech , Thank you very much for your quick response. Sorry for misunderstand this issue before.

The issuedAt and expiredAt of the struct Token are exposed for user could set them to prevent the default behavior cause this error.

ganlvtech commented 4 months ago

@richzw this pull request make me must update the StoreConfig everytime I call the API. maybe we need a func() time.Time {}

richzw commented 4 months ago

@richzw this pull request make me must update the StoreConfig everytime I call the API. maybe we need a func() time.Time {}

IMO, when your server time is correctly, there is no need to set the server time when the API is invoked every time.

In other side, it may not be convenient to update StoreConfig when the API is invoked. I am confused that func() time.Time {}. Could you please give me some sample codes for that? The usage of this function func() time.Time {}. @ganlvtech

ganlvtech commented 4 months ago

https://github.com/awa/go-iap/blob/7499815685d853c5ca0d77168b3cdfd549058849/appstore/api/store.go#L40-L48 https://github.com/awa/go-iap/blob/947c9eb50105b310e6cc20050b12c4e88dccd99e/appstore/api/token.go#L78-L85

type StoreConfig struct {
    KeyContent     []byte // Loads a .p8 certificate
    KeyID          string // Your private key ID from App Store Connect (Ex: 2X9R4HXF34)
    BundleID       string // Your app’s bundle ID
    Issuer         string // Your issuer ID from the Keys page in App Store Connect (Ex: "57246542-96fe-1a63-e053-0824d011072a")
    Sandbox        bool   // default is Production
    TokenIssueAtFunc   func() int64  // The token’s creation time, in UNIX time. Default is current timestamp.
    TokenExpiredAtFunc func() int64  // The token’s expiration time, in UNIX time. Default is one hour later.
}

func (t *Token) Generate() error {
    // ...
    issuedAt := time.Now().Unix()
    if t.IssueAtFunc != nil {
        issuedAt = t.IssueAtFunc()
    }
    expiredAt := time.Now().Add(time.Duration(1) * time.Hour).Unix()
    if t.ExpiredAtFunc != nil {
        expiredAt = t.ExpiredAtFunc()
    }
    // ...
}

func main() {
    a := api.NewStoreClient(&api.StoreConfig{
        TokenIssueAtFunc: func() int64 {
            return time.Now().Unix() - 10
        },
        TokenExpiredAtFunc: func() int64 {
            return time.Now().Unix() - 10 + 3600
        },
    })
    response, err := a.GetTransactionInfo(ctx, transactionId)
}
ganlvtech commented 4 months ago

Just like golang-jwt provides a TimeFunc instead of a exact IssuedAt ExpiredAt int value as config.

https://pkg.go.dev/github.com/golang-jwt/jwt/v5#WithTimeFunc

richzw commented 4 months ago

Just like golang-jwt provides a TimeFunc instead of a exact IssuedAt ExpiredAt int value as config.

https://pkg.go.dev/github.com/golang-jwt/jwt/v5#WithTimeFunc

Thank you very much for your detailed response.

Here are my opinions about your suggestion. Please correct me if I am misunderstanding or something missing.

  1. Even for TokenIssueAtFunc func() int64, we should update the StoreConfig every time when the API is invoked.
  2. This function TokenIssueAtFunc is assigned to variable issuedAt eventually. It seems that same to the variable TokenIssueAt int64 definition.
  3. As for the WithTimeFunc of jwt. It seems the primary use case is testing.

    WithTimeFunc returns the ParserOption for specifying the time func. The primary use-case for this is testing

  4. After quick go through the go-jwt, the WithTimeFunc used for validator.

@ganlvtech

ganlvtech commented 4 months ago

https://github.com/awa/go-iap/blob/63f7061999c16ce6ace18caef90b5cf5b5ba2ba4/appstore/api/token.go#L32-L33 https://github.com/awa/go-iap/blob/63f7061999c16ce6ace18caef90b5cf5b5ba2ba4/appstore/api/token.go#L51-L63 https://github.com/awa/go-iap/blob/63f7061999c16ce6ace18caef90b5cf5b5ba2ba4/appstore/api/store.go#L556

If I use IssuedAtFunc to replace IssuedAt field of struct Token. GenerateIfExpired() will call Generate() if needed. And then Generate() will call IssuedAtFunc() to get current time as iat. If it's IssuedAt int64 field, it will not be updated to the latest time when GenerateIfExpired() be executed.

func (t *Token) Generate() error {
    // ...
    issuedAt := time.Now().Unix()
    if t.IssuedAtFunc != nil {
        issuedAt = t.IssuedAtFunc()
    }
    expiredAt := time.Now().Add(time.Duration(1) * time.Hour).Unix()
    if t.ExpiredAtFunc != nil {
        expiredAt = t.ExpiredAtFunc()
    }
    // ...
}

By the way, it should be called IssuedAt instead of IssueAt as the RFC. https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6

One more thing, this bug is caused by Apple's misusage of JWT. JWT should be signed and issued by its server. Sign with its server time and verify with its time. There won't be 1 second difference. JWT should never signed by client itself, but it happened in Apple's API. Most client API just sign payload with timestamp, nonce and a secret, no JWT at all.

richzw commented 4 months ago

@ganlvtech

Thank you very much for your detailed response. Now I understand it and this time function could also fix the issue that the issuedAt/ExpiredAt failed to update automatically.

The related PR has been updated. Honored to have this conversation with you and I have benefited a lot.

ganlvtech commented 4 months ago

Thank you for quickly fixing this issue.

This library helps me really much. Thank you very much for your maintaining this repo. ❤️