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:
parent
b5be9311b9
commit
0125da76d0
2 changed files with 200 additions and 2 deletions
|
|
@ -113,16 +113,63 @@ async def sync_to_woocommerce(
|
|||
async def sync_to_shopify(
|
||||
products: list[dict[str, Any]],
|
||||
shop_url: str,
|
||||
access_token: str,
|
||||
access_token: str = "",
|
||||
*,
|
||||
code: str = "",
|
||||
shop_domain: str = "",
|
||||
api_key: str = "",
|
||||
api_secret: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""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:
|
||||
products: List of product dicts with name, price, description, image_url
|
||||
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 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()
|
||||
base_url = shop_url.rstrip("/")
|
||||
|
|
|
|||
151
shopify_oauth.py
Normal file
151
shopify_oauth.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue