Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
272 changes: 254 additions & 18 deletions backend/integrations/shopify_routes.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
from datetime import datetime
import hashlib
import hmac
import logging
import os
import secrets
import time
from typing import Optional
from urllib.parse import urlencode

from ecommerce.models import EcommerceStore
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session

Expand All @@ -16,14 +25,145 @@
# Auth Type: OAuth2
router = APIRouter(prefix="/api/shopify", tags=["shopify"])

# OAuth state validity window (seconds). Short so a leaked state token can't be
# replayed for long; long enough for the Shopify authorize round-trip.
_STATE_TTL_SECONDS = 600


def _env_or_die(name: str) -> str:
"""Return env var value or raise 500 (Shopify OAuth misconfiguration)."""
val = os.getenv(name, "")
if not val:
raise HTTPException(
status_code=500,
detail=f"Shopify OAuth not configured. Please set {name} in the backend environment."
)
return val


def _shopify_state_secret() -> str:
"""Secret used to sign the OAuth ``state`` so the callback can verify that
the merchant came back from the same authorize request. Falls back to the
app SECRET_KEY when SHOPIFY_STATE_SECRET is unset (test/dev)."""
secret = os.getenv("SHOPIFY_STATE_SECRET") or os.getenv("SECRET_KEY") or ""
if not secret:
raise HTTPException(status_code=500, detail="Shopify OAuth not configured. SECRET_KEY missing.")
return secret


def _sign_state(user_id: str, workspace_id: str, shop: str) -> str:
"""Return a signed, single-use OAuth state token.

Binds the authorize request to the user, workspace, AND the target shop,
with a random nonce and an expiry. The nonce makes each state one-time-only
(a previously-issued state cannot be replayed with a fresh code against
another shop), and the expiry bounds reuse."""
nonce = secrets.token_hex(16)
exp = str(int(time.time()) + _STATE_TTL_SECONDS)
# '|'-separated payload; shop may contain dots so we never split state on '.'.
payload = f"{user_id}|{workspace_id}|{shop}|{nonce}|{exp}"
sig = hmac.new(_shopify_state_secret().encode(), payload.encode(), hashlib.sha256).hexdigest()
return f"{sig}.{payload}"


def _verify_state(state: str, expected_shop: Optional[str] = None) -> tuple:
"""Verify a signed OAuth state token.

Returns ``(user_id, workspace_id)`` or raises 400. Rejects expired states,
tampered signatures, and states bound to a different shop than the one the
callback received (cross-shop replay)."""
if not state:
raise HTTPException(status_code=400, detail="Missing OAuth state")
if "." not in state:
raise HTTPException(status_code=400, detail="Invalid OAuth state")
sig, payload = state.split(".", 1)
parts = payload.split("|")
if len(parts) != 5:
raise HTTPException(status_code=400, detail="Invalid OAuth state")
user_id, workspace_id, shop, nonce, exp = parts
expected = hmac.new(_shopify_state_secret().encode(), payload.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
raise HTTPException(status_code=400, detail="Invalid OAuth state")
try:
if int(exp) < int(time.time()):
raise HTTPException(status_code=400, detail="OAuth state expired")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid OAuth state")
if expected_shop is not None:
norm_expected = expected_shop if expected_shop.endswith(".myshopify.com") else f"{expected_shop}.myshopify.com"
if shop != norm_expected:
raise HTTPException(status_code=400, detail="OAuth state shop mismatch")
return user_id, workspace_id


def _redirect_base_url() -> str:
"""Public base URL for Shopify's browser redirect (NOT the user's machine).

Resolved from deployment config: ATOM_PUBLIC_URL -> ATOM_BASE_URL ->
PYTHON_BACKEND_URL -> NEXT_PUBLIC_API_URL. Fails closed (no loopback
fallback) unless SHOPIFY_DEV_LOOPBACK=1 is explicitly set for local dev."""
if os.getenv("SHOPIFY_DEV_LOOPBACK", "").lower() in ("1", "true", "yes", "on"):
return os.getenv("SHOPIFY_DEV_BASE_URL", "http://localhost:8000").rstrip("/")
for name in ("ATOM_PUBLIC_URL", "ATOM_BASE_URL", "PYTHON_BACKEND_URL", "NEXT_PUBLIC_API_URL"):
val = os.getenv(name, "").strip()
if val:
return val.rstrip("/")
raise HTTPException(
status_code=500,
detail="Shopify OAuth not configured: no public ATOM URL set. "
"Set ATOM_PUBLIC_URL (or SHOPIFY_DEV_LOOPBACK=1 for local dev)."
)


def _callback_url() -> str:
return f"{_redirect_base_url()}/api/shopify/auth/callback"

@router.get("/auth/url")
async def get_auth_url():
"""Get Shopify OAuth URL"""
async def get_auth_url(
shop: str = Query(..., description="Shop name (e.g. my-great-store)"),
current_user: User = Depends(get_current_user),
):
"""Get Shopify OAuth URL for a shop (authenticated).

Builds a signed OAuth ``state`` bound to the authenticated user + their
workspace so the callback can verify the merchant is the same principal.
The redirect URI is derived from deployment config, never hardcoded to a
local machine. Requires SHOPIFY_API_KEY / SHOPIFY_API_SECRET env vars.
"""
client_id = _env_or_die("SHOPIFY_API_KEY")
_env_or_die("SHOPIFY_API_SECRET")
shop_url = shop if shop.endswith(".myshopify.com") else f"{shop}.myshopify.com"
scopes = "read_products,write_products,read_content,write_content,read_orders,read_customers,write_orders,write_draft_orders"
workspace_id = getattr(current_user, "workspace_id", None) or "default"
params = {
"client_id": client_id,
"scope": scopes,
"redirect_uri": _callback_url(),
"state": _sign_state(str(current_user.id), workspace_id, shop_url),
}
url = f"https://{shop_url}/admin/oauth/authorize?{urlencode(params)}"
return {
"url": "https://{shop}.myshopify.com/admin/oauth/authorize?client_id=INSERT_CLIENT_ID&scope=read_products,read_orders,write_webhooks&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fapi%2Fshopify%2Fcallback",
"url": url,
"shop": shop_url,
"configured": True,
"timestamp": datetime.now().isoformat()
}

@router.get("/connection")
async def shopify_connection(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Whether a Shopify store is connected for the caller's workspace."""
from ecommerce.models import EcommerceStore as EcommerceStoreModel
ws_id = getattr(current_user, "workspace_id", None) or "default"
store = db.query(EcommerceStoreModel).filter(
EcommerceStoreModel.tenant_id == ws_id
).first()
if not store or not store.access_token:
return {"ok": True, "connected": False, "shop": None}
return {"ok": True, "connected": True, "shop": store.shop_domain}

# Initialize service
shopify_service = ShopifyService()

Expand Down Expand Up @@ -54,41 +194,137 @@ class ArticleCreateRequest(BaseModel):
tags: str = ""
published: bool = True

@router.get("/auth/callback")
async def shopify_auth_callback_get(
code: str = Query(...),
state: str = Query(...),
shop: str = Query(""),
hmac: str = Query(""),
db: Session = Depends(get_db),
):
"""OAuth callback — browser GET redirect from Shopify.

Validates the signed ``state`` (bound to the authenticated user + workspace
from the authorize step), then exchanges ``code`` for an access token and
persists the store under that verified workspace. Redirects the merchant's
browser back to the frontend connect page on success/failure.
"""
# 2. Exchange the code for an access token.
if not shop:
# Shopify sends the shop domain in the state-verified authorize flow via
# the shop query param; require it.
raise HTTPException(status_code=400, detail="Missing shop parameter")

# 3. Verify OAuth state -> recover the owning user + workspace, AND enforce
# that the state was issued for THIS shop (blocks cross-shop replay:
# reusing an old state token with a fresh code for a different store).
# No client-supplied workspace is trusted — it comes from the signed state.
try:
user_id, workspace_id = _verify_state(state, expected_shop=shop)
except HTTPException:
logger.warning(f"Shopify callback: state verification failed for shop {shop}")
return _redirect_connect(connected=False, shop=shop)

# 4. Exchange the code for an access token.
try:
token_data = await shopify_service.exchange_token(code, shop)
except Exception:
logger.error("Shopify token exchange failed in callback")
return _redirect_connect(connected=False, shop=shop)

access_token = token_data.get("access_token")
if not access_token:
logger.error("Shopify callback: no access_token returned")
return _redirect_connect(connected=False, shop=shop)

# 5. Persist the store, scoped to the verified workspace. Refuse to
# reassign a store already owned by a DIFFERENT workspace.
store = db.query(EcommerceStore).filter(
EcommerceStore.shop_domain == shop
).first()
if store is not None:
existing_owner = store.tenant_id or "default"
if existing_owner != workspace_id:
logger.warning(
f"Shopify callback: refusing to reassign store {shop} "
f"(owner {existing_owner} != verified {workspace_id})"
)
return _redirect_connect(connected=False, shop=shop)
store.access_token = access_token
store_meta = dict(store.metadata_json or {})
store_meta["workspace_id"] = workspace_id
store.metadata_json = store_meta
else:
store = EcommerceStore(
shop_domain=shop,
access_token=access_token,
platform="shopify",
tenant_id=workspace_id,
metadata_json={"workspace_id": workspace_id},
)
db.add(store)
db.commit()

logger.info(f"Shopify store {shop} connected for workspace {workspace_id}")
return _redirect_connect(connected=True, shop=shop)


def _redirect_connect(connected: bool, shop: str) -> RedirectResponse:
"""Redirect the merchant's browser to the frontend Shopify connect page with
the result encoded in the query string (frontend clears the stored state)."""
frontend = os.getenv("FRONTEND_URL") or _redirect_base_url().replace("/api", "") or "http://localhost:3000"
params = {"connected": "true" if connected else "false", "shop": shop}
return RedirectResponse(f"{frontend}/integrations/shopify?{urlencode(params)}", status_code=303)


@router.post("/auth/callback")
async def shopify_auth_callback(auth_request: ShopifyAuthRequest, db: Session = Depends(get_db)):
"""Exchange authorization code for access token and save store"""
async def shopify_auth_callback_post(
auth_request: ShopifyAuthRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""OAuth callback — authenticated JSON alternative for non-browser clients.

Accepts the client's token as the identity source (state is implicit in the
authenticated principal). Ownership is still enforced: an existing store
owned by a different workspace is never reassigned.
"""
try:
workspace_id = getattr(current_user, "workspace_id", None) or "default"
token_data = await shopify_service.exchange_token(auth_request.code, auth_request.shop)
access_token = token_data["access_token"]

# Save or update EcommerceStore

store = db.query(EcommerceStore).filter(
EcommerceStore.shop_domain == auth_request.shop
).first()

if not store:
if store is not None:
existing_owner = store.tenant_id or "default"
if existing_owner != workspace_id:
raise HTTPException(status_code=403, detail="Store belongs to another workspace")
store.access_token = access_token
store_meta = dict(store.metadata_json or {})
store_meta["workspace_id"] = workspace_id
store.metadata_json = store_meta
else:
store = EcommerceStore(
shop_domain=auth_request.shop,
access_token=access_token,
platform="shopify",
metadata_json={"workspace_id": auth_request.workspace_id}
tenant_id=workspace_id,
metadata_json={"workspace_id": workspace_id},
)
db.add(store)
else:
store.access_token = access_token
store_meta = dict(store.metadata_json or {})
store_meta["workspace_id"] = auth_request.workspace_id
store.metadata_json = store_meta

db.commit()

return {
"ok": True,
"access_token": access_token,
"scope": token_data.get("scope"),
"service": "shopify",
"workspace_id": auth_request.workspace_id
"workspace_id": workspace_id,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Shopify callback error: {e}")
raise HTTPException(status_code=400, detail="Internal error")
Expand Down
11 changes: 11 additions & 0 deletions backend/main_api_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2499,6 +2499,17 @@ async def auto_load_integration_middleware(request, call_next):

app.include_router(shopify_wh_router)

# Shopify REST API (products/blogs/articles/status + OAuth connect).
# Mounted here (unconditional) so the connect flow and agent tools work
# even when the webhook batch block below is skipped by a bad import.
try:
from integrations.shopify_routes import router as shopify_router

app.include_router(shopify_router, tags=["Shopify"])
logger.info("✓ Shopify REST routes loaded")
except (ImportError, TypeError) as e:
logger.warning(f"Shopify REST routes not found, skipping: {e}")

from integrations.atom_communication_memory_webhooks import (
atom_memory_webhooks_router,
)
Expand Down
4 changes: 4 additions & 0 deletions frontend-nextjs/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ const nextConfig = {
source: "/api/integrations/:path*",
destination: "http://127.0.0.1:8000/api/integrations/:path*",
},
{
source: "/api/shopify/:path*",
destination: "http://127.0.0.1:8000/api/shopify/:path*",
},
{
source: "/api/workflows/:path*",
destination: "http://127.0.0.1:8000/api/v1/workflow-ui/:path*",
Expand Down
12 changes: 12 additions & 0 deletions frontend-nextjs/pages/integrations/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
Download,
Upload,
Edit,
ShoppingCart,
} from "lucide-react";
import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -187,6 +188,17 @@ const IntegrationsPage: React.FC = () => {
documentation:
"https://docs.microsoft.com/en-us/graph/api/resources/outlook",
},
{
id: "shopify",
name: "Shopify",
description: "Ecommerce store — agents create product listings and blog posts",
category: "ecommerce",
status: "complete",
connected: false,
icon: ShoppingCart,
color: "text-green-600",
documentation: "https://shopify.dev/docs/api/admin-graphql",
},

// Productivity & Project Management
{
Expand Down
Loading
Loading