From 18c2bcfbed6733af0da1797d78bfdce1ae5da2bc Mon Sep 17 00:00:00 2001 From: Wei Zang Date: Wed, 5 Feb 2025 18:00:29 +0800 Subject: [PATCH] feat: support window store --- microsoftstore/model.go | 71 ++++++++++++++++++++ microsoftstore/validator.go | 125 ++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 microsoftstore/model.go create mode 100644 microsoftstore/validator.go diff --git a/microsoftstore/model.go b/microsoftstore/model.go new file mode 100644 index 0000000..d6f81b5 --- /dev/null +++ b/microsoftstore/model.go @@ -0,0 +1,71 @@ +package microsoftstore + +import "time" + +type UserIdentity struct { + IdentityType string `json:"identityType"` + IdentityValue string `json:"identityValue"` + LocalTicketReference string `json:"localTicketReference"` +} + +type ProductSkuId struct { + ProductId string `json:"productId"` + SkuId string `json:"skuId"` +} + +type ProductType string + +const ( + Application ProductType = "Application" + Durable ProductType = "Durable" + Game ProductType = "Game" + UnmanagedConsumable ProductType = "UnmanagedConsumable" +) + +type IAPRequest struct { + Beneficiaries []UserIdentity `json:"beneficiaries"` + ContinuationToken string `json:"continuationToken,omitempty"` + MaxPageSize int `json:"maxPageSize,omitempty"` + ModifiedAfter *time.Time `json:"modifiedAfter,omitempty"` + ParentProductId string `json:"parentProductId,omitempty"` + ProductSkuIds []ProductSkuId `json:"productSkuIds,omitempty"` + ProductTypes []ProductType `json:"productTypes"` + ValidityType string `json:"validityType,omitempty"` +} + +type IdentityContractV6 struct { + IdentityType string `json:"identityType"` // Contains the value "pub". + IdentityValue string `json:"identityValue"` // The string value of the publisherUserId from the specified Microsoft Store ID key. +} + +// CollectionItemContractV6 represents an item in the user's collection. +type CollectionItemContractV6 struct { + AcquiredDate time.Time `json:"acquiredDate"` // The date on which the user acquired the item. + CampaignId *string `json:"campaignId,omitempty"` // The campaign ID that was provided at purchase time for this item. + DevOfferId *string `json:"devOfferId,omitempty"` // The offer ID from an in-app purchase. + EndDate time.Time `json:"endDate"` // The end date of the item. + FulfillmentData []string `json:"fulfillmentData,omitempty"` // N/A + InAppOfferToken *string `json:"inAppOfferToken,omitempty"` // The developer-specified product ID string assigned to the item in Partner Center. + ItemId string `json:"itemId"` // An ID that identifies this collection item from other items the user owns. + LocalTicketReference string `json:"localTicketReference"` // The ID of the previously supplied localTicketReference in the request body. + ModifiedDate time.Time `json:"modifiedDate"` // The date this item was last modified. + OrderId *string `json:"orderId,omitempty"` // If present, the order ID of which this item was obtained. + OrderLineItemId *string `json:"orderLineItemId,omitempty"` // If present, the line item of the particular order for which this item was obtained. + OwnershipType string `json:"ownershipType"` // The string "OwnedByBeneficiary". + ProductId string `json:"productId"` // The Store ID for the product in the Microsoft Store catalog. + ProductType string `json:"productType"` // One of the following product types: Application, Durable, UnmanagedConsumable. + PurchasedCountry *string `json:"purchasedCountry,omitempty"` // N/A + Purchaser *IdentityContractV6 `json:"purchaser,omitempty"` // Represents the identity of the purchaser of the item. + Quantity *int `json:"quantity,omitempty"` // The quantity of the item. Currently, this will always be 1. + SkuId string `json:"skuId"` // The Store ID for the product's SKU in the Microsoft Store catalog. + SkuType string `json:"skuType"` // Type of the SKU. Possible values include Trial, Full, and Rental. + StartDate time.Time `json:"startDate"` // The date that the item starts being valid. + Status string `json:"status"` // The status of the item. Possible values include Active, Expired, Revoked, and Banned. + Tags []string `json:"tags"` // N/A + TransactionId string `json:"transactionId"` // The transaction ID as a result of the purchase of this item. +} + +type IAPResponse struct { + ContinuationToken *string `json:"continuationToken,omitempty"` // Token to retrieve remaining products if there are multiple sets. + Items []CollectionItemContractV6 `json:"items,omitempty"` // An array of products for the specified user. +} diff --git a/microsoftstore/validator.go b/microsoftstore/validator.go new file mode 100644 index 0000000..d2cc06c --- /dev/null +++ b/microsoftstore/validator.go @@ -0,0 +1,125 @@ +package microsoftstore + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const ( + resource = "https://onestore.microsoft.com" +) + +// IAPClient is an interface to call validation API in Microsoft Store +type IAPClient interface { + Verify(context.Context, string, string) (IAPResponse, error) +} + +// Client implements IAPClient +type Client struct { + TenantID string + ClientID string + ClientSecret string + httpCli *http.Client +} + +// New creates a client object +func New(tenantId, clientId, secret string) *Client { + client := &Client{ + TenantID: tenantId, + ClientID: clientId, + ClientSecret: secret, + httpCli: &http.Client{ + Timeout: 10 * time.Second, + }, + } + + return client +} + +// Verify sends receipts and gets validation result +func (c *Client) Verify(ctx context.Context, receipt IAPRequest) (IAPResponse, error) { + resp := IAPResponse{} + token, err := c.getAzureADToken(ctx, c.TenantID, c.ClientID, c.ClientSecret, resource) + if err != nil { + return resp, err + } + + return c.query(ctx, token, receipt) +} + +// getAzureADToken obtains an Azure AD access token using client credentials flow +func (c *Client) getAzureADToken(ctx context.Context, tenantID, clientID, clientSecret, resource string) (string, error) { + tokenURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/token", tenantID) + + data := url.Values{} + data.Set("grant_type", "client_credentials") + data.Set("client_id", clientID) + data.Set("client_secret", clientSecret) + data.Set("resource", resource) + + req, err := http.NewRequest("POST", tokenURL, bytes.NewBufferString(data.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.WithContext(ctx) + + resp, err := c.httpCli.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("failed to obtain token: %s", string(bodyBytes)) + } + + var tokenResponse struct { + AccessToken string `json:"access_token"` + } + err = json.NewDecoder(resp.Body).Decode(&tokenResponse) + if err != nil { + return "", err + } + return tokenResponse.AccessToken, nil +} + +// query sends a query to Microsoft Store API +func (c *Client) query(ctx context.Context, accessToken string, receiptData IAPRequest) (IAPResponse, error) { + queryURL := "https://collections.mp.microsoft.com/v6.0/collections/query" + result := IAPResponse{} + + requestBody, err := json.Marshal(receiptData) + if err != nil { + return result, err + } + + req, err := http.NewRequest("POST", queryURL, bytes.NewBuffer(requestBody)) + if err != nil { + return result, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+accessToken) + req.WithContext(ctx) + + res, err := c.httpCli.Do(req) + if err != nil { + return result, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(res.Body) + return result, fmt.Errorf("validation failed: %s", string(bodyBytes)) + } + + err = json.NewDecoder(res.Body).Decode(&result) + return result, err +}