Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user