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