Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support window store #309

Merged
merged 1 commit into from
Feb 5, 2025
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
71 changes: 71 additions & 0 deletions microsoftstore/model.go
Original file line number Diff line number Diff line change
@@ -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.
}
125 changes: 125 additions & 0 deletions microsoftstore/validator.go
Original file line number Diff line number Diff line change
@@ -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
}