136 lines
4.0 KiB
Go
136 lines
4.0 KiB
Go
package shopify
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
authModeLegacyToken = "legacy_token"
|
||
|
|
authModeClientCredentials = "client_credentials"
|
||
|
|
tokenRefreshSkew = 2 * time.Minute
|
||
|
|
maxTokenBodyBytes = 1 << 20
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
ErrInvalidClientCredentials = errors.New("shopify client id and secret are required together")
|
||
|
|
ErrTokenExchangeFailed = errors.New("shopify token exchange failed")
|
||
|
|
)
|
||
|
|
|
||
|
|
// AccessTokenResult is the Admin API token from client_credentials grant.
|
||
|
|
type AccessTokenResult struct {
|
||
|
|
AccessToken string
|
||
|
|
Scope string
|
||
|
|
ExpiresIn int
|
||
|
|
ExpiresAt time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
type tokenExchangeResponse struct {
|
||
|
|
AccessToken string `json:"access_token"`
|
||
|
|
Scope string `json:"scope"`
|
||
|
|
ExpiresIn int `json:"expires_in"`
|
||
|
|
Error string `json:"error"`
|
||
|
|
ErrorDesc string `json:"error_description"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// ExchangeClientCredentials requests a short-lived Admin API token (≈24h).
|
||
|
|
// shopHost must be a normalized *.myshopify.com host. Uses SSRF-safe dialing via httpClient.
|
||
|
|
func ExchangeClientCredentials(ctx context.Context, httpClient *http.Client, shopHost, clientID, clientSecret string) (AccessTokenResult, error) {
|
||
|
|
clientID = strings.TrimSpace(clientID)
|
||
|
|
clientSecret = strings.TrimSpace(clientSecret)
|
||
|
|
if clientID == "" || clientSecret == "" {
|
||
|
|
return AccessTokenResult{}, ErrInvalidClientCredentials
|
||
|
|
}
|
||
|
|
shopHost = strings.TrimSpace(strings.ToLower(shopHost))
|
||
|
|
shopHost = strings.TrimPrefix(shopHost, "https://")
|
||
|
|
shopHost = strings.TrimPrefix(shopHost, "http://")
|
||
|
|
tokenURL := AdminBaseURL(shopHost) + "/admin/oauth/access_token"
|
||
|
|
if err := GuardAdminURL(tokenURL, shopHost); err != nil {
|
||
|
|
return AccessTokenResult{}, err
|
||
|
|
}
|
||
|
|
if httpClient == nil {
|
||
|
|
httpClient = security.SafeHTTPClient(defaultTimeout, false)
|
||
|
|
}
|
||
|
|
|
||
|
|
form := url.Values{}
|
||
|
|
form.Set("grant_type", "client_credentials")
|
||
|
|
form.Set("client_id", clientID)
|
||
|
|
form.Set("client_secret", clientSecret)
|
||
|
|
|
||
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
||
|
|
if err != nil {
|
||
|
|
return AccessTokenResult{}, err
|
||
|
|
}
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
req.Header.Set("Accept", "application/json")
|
||
|
|
req.Header.Set("User-Agent", "Descrybe-Shopify/2.0")
|
||
|
|
|
||
|
|
res, err := httpClient.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return AccessTokenResult{}, err
|
||
|
|
}
|
||
|
|
defer res.Body.Close()
|
||
|
|
|
||
|
|
limited := io.LimitReader(res.Body, maxTokenBodyBytes+1)
|
||
|
|
raw, err := io.ReadAll(limited)
|
||
|
|
if err != nil {
|
||
|
|
return AccessTokenResult{}, err
|
||
|
|
}
|
||
|
|
if len(raw) > maxTokenBodyBytes {
|
||
|
|
return AccessTokenResult{}, fmt.Errorf("%w: response too large", ErrTokenExchangeFailed)
|
||
|
|
}
|
||
|
|
|
||
|
|
parsed, err := parseAccessTokenResponse(raw)
|
||
|
|
if err != nil {
|
||
|
|
return AccessTokenResult{}, err
|
||
|
|
}
|
||
|
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||
|
|
detail := firstNonEmpty(parsed.ErrorDesc, parsed.Error, strings.TrimSpace(string(raw)))
|
||
|
|
if len(detail) > 400 {
|
||
|
|
detail = detail[:400] + "…"
|
||
|
|
}
|
||
|
|
return AccessTokenResult{}, fmt.Errorf("%w: HTTP %d: %s", ErrTokenExchangeFailed, res.StatusCode, detail)
|
||
|
|
}
|
||
|
|
if parsed.AccessToken == "" {
|
||
|
|
return AccessTokenResult{}, fmt.Errorf("%w: empty access_token", ErrTokenExchangeFailed)
|
||
|
|
}
|
||
|
|
expiresIn := parsed.ExpiresIn
|
||
|
|
if expiresIn <= 0 {
|
||
|
|
expiresIn = 86399
|
||
|
|
}
|
||
|
|
now := time.Now().UTC()
|
||
|
|
return AccessTokenResult{
|
||
|
|
AccessToken: parsed.AccessToken,
|
||
|
|
Scope: parsed.Scope,
|
||
|
|
ExpiresIn: expiresIn,
|
||
|
|
ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseAccessTokenResponse(raw []byte) (tokenExchangeResponse, error) {
|
||
|
|
var parsed tokenExchangeResponse
|
||
|
|
if len(raw) == 0 {
|
||
|
|
return parsed, fmt.Errorf("%w: empty body", ErrTokenExchangeFailed)
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||
|
|
return parsed, fmt.Errorf("%w: invalid json", ErrTokenExchangeFailed)
|
||
|
|
}
|
||
|
|
return parsed, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func tokenNeedsRefresh(expiresAt *time.Time, now time.Time) bool {
|
||
|
|
if expiresAt == nil {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
return !expiresAt.After(now.Add(tokenRefreshSkew))
|
||
|
|
}
|