Files

136 lines
4.0 KiB
Go
Raw Permalink Normal View History

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))
}