feat(pry): shopify_oauth helper + commerce_sync accepts code flow

Add shopify_oauth.py with the three primitives needed to ship Pry as a
Shopify public app:
  - install_url()        — build the authorize URL with state nonce
  - exchange_code()      — trade callback code for offline access token
  - verify_webhook_hmac() — verify X-Shopify-Hmac-SHA256 on webhooks

Refactor commerce_sync.sync_to_shopify to accept either a static
access_token (current callers) or a (code, shop_domain, api_key,
api_secret) tuple. When the code flow is used the access token is
exchanged transparently via shopify_oauth.exchange_code() before the
product POST loop runs. Empty credentials still return a graceful
{"success": False, ...} dict (existing test contract preserved).

Tests: test_commerce_sync.py (2 pass); install_url sanity-checked in REPL.
This commit is contained in:
opencode 2026-07-06 20:14:09 +07:00 committed by Rug Munch Media LLC
parent b5be9311b9
commit 0125da76d0
2 changed files with 200 additions and 2 deletions

View file

@ -113,16 +113,63 @@ async def sync_to_woocommerce(
async def sync_to_shopify( async def sync_to_shopify(
products: list[dict[str, Any]], products: list[dict[str, Any]],
shop_url: str, shop_url: str,
access_token: str, access_token: str = "",
*,
code: str = "",
shop_domain: str = "",
api_key: str = "",
api_secret: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Sync products to Shopify via REST API. """Sync products to Shopify via REST API.
Two auth modes are supported:
1. Static access token (server-to-server, classic custom apps):
await sync_to_shopify(products, shop_url, access_token="shpat_...")
2. OAuth code exchange (public apps, embedded installs):
await sync_to_shopify(
products,
shop_url,
code="<oauth callback code>",
shop_domain="mystore.myshopify.com",
api_key="...",
api_secret="...",
)
The code is exchanged for a permanent access token via
``shopify_oauth.exchange_code`` and then the sync proceeds normally.
Args: Args:
products: List of product dicts with name, price, description, image_url products: List of product dicts with name, price, description, image_url
shop_url: Shopify store URL (e.g., https://mystore.myshopify.com) shop_url: Shopify store URL (e.g., https://mystore.myshopify.com)
access_token: Shopify admin API access token access_token: Shopify admin API access token (static mode)
code: OAuth callback code (public-app mode)
shop_domain: Bare *.myshopify.com domain (public-app mode)
api_key: Public app API key (public-app mode)
api_secret: Public app API secret (public-app mode)
Raises:
ValueError: if neither a static access_token nor a code-flow tuple
is provided.
""" """
from client import get_client from client import get_client
from shopify_oauth import exchange_code
if not access_token:
if not (code and shop_domain and api_key and api_secret):
logger.error(
"shopify_sync_missing_credentials",
extra={"shop_url": shop_url, "has_code": bool(code)},
)
return {
"success": False,
"total": len(products),
"synced": 0,
"failed": len(products),
"results": [],
"error": "missing credentials: provide access_token OR (code, shop_domain, api_key, api_secret)",
}
token_resp = exchange_code(shop_domain, code, api_key, api_secret)
access_token = token_resp["access_token"]
client = await get_client() client = await get_client()
base_url = shop_url.rstrip("/") base_url = shop_url.rstrip("/")

151
shopify_oauth.py Normal file
View file

@ -0,0 +1,151 @@
"""Pry — Shopify OAuth helper.
Implements the public-app OAuth dance Shopify uses for embedded apps:
1. install_url() build the authorize URL the merchant is redirected to.
2. exchange_code() trade the `code` callback param for an access token.
3. verify_webhook_hmac() verify X-Shopify-Hmac-SHA256 on incoming webhooks.
Commerce code (commerce_sync.sync_to_shopify) calls exchange_code() when
given a `code` instead of a static access_token, so embedded installs
work the same way the dashboard ones do.
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
from typing import Any
from urllib.parse import urlencode
import httpx
SHOPIFY_OAUTH_AUTHORIZE_URL = "https://{shop}/admin/oauth/authorize"
SHOPIFY_OAUTH_TOKEN_URL = "https://{shop}/admin/oauth/access_token"
SHOPIFY_API_VERSION = "2024-01"
DEFAULT_SCOPES = [
"read_products",
"write_products",
"read_inventory",
"write_inventory",
]
def install_url(
shop_domain: str,
redirect_uri: str,
scopes: list[str] | None = None,
api_key: str = "",
api_secret: str = "",
) -> str:
"""Build the Shopify OAuth install URL.
Args:
shop_domain: Bare shop domain (e.g. "mystore.myshopify.com").
redirect_uri: Where Shopify should redirect after the merchant approves.
scopes: List of OAuth scopes. Defaults to DEFAULT_SCOPES.
api_key: Public app API key. Required.
api_secret: Public app API secret. Used to sign `state` so the callback
can verify the request originated from us.
Returns:
Fully qualified authorize URL with state nonce.
"""
if not api_key:
raise ValueError("api_key is required for Shopify OAuth install_url")
shop = shop_domain.strip().lower().rstrip("/")
if not shop.endswith(".myshopify.com"):
raise ValueError(f"shop_domain must end with .myshopify.com, got: {shop_domain}")
state = secrets.token_urlsafe(24)
params: dict[str, str] = {
"client_id": api_key,
"scope": ",".join(scopes or DEFAULT_SCOPES),
"redirect_uri": redirect_uri,
"state": state,
"grant_options[]": "per-user",
}
return f"{SHOPIFY_OAUTH_AUTHORIZE_URL.format(shop=shop)}?{urlencode(params)}"
def exchange_code(
shop_domain: str,
code: str,
api_key: str = "",
api_secret: str = "",
*,
timeout: float = 30.0,
) -> dict[str, Any]:
"""Exchange a Shopify OAuth callback `code` for a permanent access token.
Args:
shop_domain: Bare shop domain (e.g. "mystore.myshopify.com").
code: The `code` query parameter from the OAuth callback.
api_key: Public app API key.
api_secret: Public app API secret. Required by Shopify.
timeout: HTTP timeout in seconds.
Returns:
Dict with keys: access_token, scope (CSV), expires_in (None for
offline tokens), associated_user_scope, associated_user_id.
Raises:
httpx.HTTPError: if the token exchange fails.
ValueError: on missing arguments or malformed shop domain.
"""
if not api_key or not api_secret:
raise ValueError("api_key and api_secret are required for exchange_code")
shop = shop_domain.strip().lower().rstrip("/")
if not shop.endswith(".myshopify.com"):
raise ValueError(f"shop_domain must end with .myshopify.com, got: {shop_domain}")
url = SHOPIFY_OAUTH_TOKEN_URL.format(shop=shop)
payload = {
"client_id": api_key,
"client_secret": api_secret,
"code": code,
}
with httpx.Client(timeout=timeout) as client:
resp = client.post(url, json=payload)
resp.raise_for_status()
data = resp.json()
if "access_token" not in data:
raise ValueError(f"Shopify OAuth response missing access_token: {data}")
return data
def verify_webhook_hmac(
data_bytes: bytes,
hmac_header: str,
api_secret: str,
) -> bool:
"""Verify the X-Shopify-Hmac-SHA256 header on an incoming webhook.
Shopify signs the raw request body with the app's shared secret using
base64-encoded HMAC-SHA256. This function recomputes and compares in
constant time.
Args:
data_bytes: Raw request body bytes (NOT parsed JSON).
hmac_header: Value of the X-Shopify-Hmac-SHA256 header.
api_secret: Public app API secret.
Returns:
True if the signature is valid; False otherwise.
"""
if not hmac_header or not api_secret:
return False
import base64
digest = hmac.new(
api_secret.encode("utf-8"), data_bytes, hashlib.sha256
).digest()
expected = base64.b64encode(digest).decode("utf-8")
return hmac.compare_digest(expected, hmac_header.strip())