From 616d8a488275050707a40ac07fe31e645941f215 Mon Sep 17 00:00:00 2001 From: ichandrasharma Date: Fri, 21 Aug 2026 17:25:08 +0530 Subject: [PATCH 1/3] feat(shopify): implement Shopify integration with OAuth flow and connection check --- backend/integrations/shopify_routes.py | 55 ++++++- backend/main_api_app.py | 11 ++ frontend-nextjs/next.config.js | 4 + frontend-nextjs/pages/integrations/index.tsx | 12 ++ .../pages/integrations/shopify.tsx | 139 ++++++++++++++++++ 5 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 frontend-nextjs/pages/integrations/shopify.tsx diff --git a/backend/integrations/shopify_routes.py b/backend/integrations/shopify_routes.py index ee72a85eb..5ab558403 100644 --- a/backend/integrations/shopify_routes.py +++ b/backend/integrations/shopify_routes.py @@ -17,13 +17,55 @@ router = APIRouter(prefix="/api/shopify", tags=["shopify"]) @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. + + Requires SHOPIFY_API_KEY / SHOPIFY_API_SECRET env vars (Azure-level app + credentials). Redirects back to /api/shopify/auth/callback after approval. + """ + import os + client_id = os.getenv("SHOPIFY_API_KEY", "") + if not client_id: + raise HTTPException( + status_code=500, + detail="Shopify OAuth not configured. Please set SHOPIFY_API_KEY and SHOPIFY_API_SECRET environment variables." + ) + 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" + redirect_uri = "http://localhost:8000/api/shopify/auth/callback" + params = { + "client_id": client_id, + "scope": scopes, + "redirect_uri": redirect_uri, + "state": str(current_user.id), + } + from urllib.parse import urlencode + 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() @@ -61,21 +103,26 @@ async def shopify_auth_callback(auth_request: ShopifyAuthRequest, db: Session = token_data = await shopify_service.exchange_token(auth_request.code, auth_request.shop) access_token = token_data["access_token"] - # Save or update EcommerceStore + # Save or update EcommerceStore. tenant_id must equal the workspace id + # so the agent's shopify_* tools / action resolver (which scope by + # tenant_id == workspace_id) can find the connected store. store = db.query(EcommerceStore).filter( EcommerceStore.shop_domain == auth_request.shop ).first() + tenant_id = auth_request.workspace_id or "default" if not store: store = EcommerceStore( shop_domain=auth_request.shop, access_token=access_token, platform="shopify", + tenant_id=tenant_id, metadata_json={"workspace_id": auth_request.workspace_id} ) db.add(store) else: store.access_token = access_token + store.tenant_id = tenant_id store_meta = dict(store.metadata_json or {}) store_meta["workspace_id"] = auth_request.workspace_id store.metadata_json = store_meta diff --git a/backend/main_api_app.py b/backend/main_api_app.py index 623b4e55c..3f2e86796 100644 --- a/backend/main_api_app.py +++ b/backend/main_api_app.py @@ -2436,6 +2436,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, ) diff --git a/frontend-nextjs/next.config.js b/frontend-nextjs/next.config.js index 7be03b487..9e5c07fa5 100644 --- a/frontend-nextjs/next.config.js +++ b/frontend-nextjs/next.config.js @@ -79,6 +79,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*", diff --git a/frontend-nextjs/pages/integrations/index.tsx b/frontend-nextjs/pages/integrations/index.tsx index d73838811..64f4d1546 100644 --- a/frontend-nextjs/pages/integrations/index.tsx +++ b/frontend-nextjs/pages/integrations/index.tsx @@ -46,6 +46,7 @@ import { Download, Upload, Edit, + ShoppingCart, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -176,6 +177,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 { diff --git a/frontend-nextjs/pages/integrations/shopify.tsx b/frontend-nextjs/pages/integrations/shopify.tsx new file mode 100644 index 000000000..afa4ccc5c --- /dev/null +++ b/frontend-nextjs/pages/integrations/shopify.tsx @@ -0,0 +1,139 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { NextPage } from "next"; +import Head from "next/head"; +import { useRouter } from "next/router"; + +const ShopifyPage: NextPage = () => { + const router = useRouter(); + const { connected, shop } = router.query as { connected?: string; shop?: string }; + const [isConnected, setIsConnected] = useState(connected === "true"); + const [connectedShop, setConnectedShop] = useState(shop || ""); + const [shopName, setShopName] = useState(""); + const [loading, setLoading] = useState(true); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(null); + + const getToken = () => + typeof window !== "undefined" + ? localStorage.getItem("auth_token") || localStorage.getItem("token") + : null; + + const checkConnection = useCallback(async () => { + try { + const token = getToken(); + const res = await fetch("/api/shopify/connection", { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (res.ok) { + const data = await res.json(); + setIsConnected(data.connected === true); + setConnectedShop(data.shop || ""); + } + } catch (e) { + console.error("Shopify connection check failed:", e); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + checkConnection(); + }, [checkConnection]); + + const handleConnect = async () => { + const name = shopName.trim(); + if (!name) { + setError("Please enter your Shopify shop name (e.g. my-great-store)."); + return; + } + setError(null); + setConnecting(true); + try { + const token = getToken(); + const res = await fetch( + `/api/shopify/auth/url?shop=${encodeURIComponent(name)}`, + { headers: token ? { Authorization: `Bearer ${token}` } : {} }, + ); + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.detail || "Failed to get authorization URL"); + } + const data = await res.json(); + if (data.url) { + window.location.href = data.url; + } else { + throw new Error("No authorization URL returned"); + } + } catch (e: any) { + setError(e?.message || "Failed to initiate Shopify connection."); + setConnecting(false); + } + }; + + return ( + <> + + Shopify | ATOM + +
+

Shopify

+

+ Connect your Shopify store so agents can create product + listings and publish blog posts automatically. +

+ + {loading ? ( +
Checking connection...
+ ) : isConnected ? ( +
+
+ Connected to {connectedShop || "your Shopify store"} +
+

+ Agents can now create listings and blog posts. Ask your + assistant things like: "Shopify pe naya product listing + banao" ya "Blog post likho aur publish karo". +

+ +
+ ) : ( +
+

Connect your store

+ {error && ( +
{error}
+ )} + + setShopName(e.target.value)} + /> +
+ +

+ Requires SHOPIFY_API_KEY / SHOPIFY_API_SECRET in the backend + environment and write_products / write_content scopes. +

+
+ )} +
+ + ); +}; + +export default ShopifyPage; \ No newline at end of file From 8d0eef7e944d24e31d0f0f325585a09c5766e5ce Mon Sep 17 00:00:00 2001 From: ichandrasharma Date: Fri, 21 Aug 2026 17:48:59 +0530 Subject: [PATCH 2/3] fix(shopify): secure OAuth callback + deployment-safe connect - GET callback route for Shopify browser redirect (was POST-only -> 405) - callback URI derived from deployment config (ATOM_PUBLIC_URL etc.), not hardcoded localhost - signed OAuth state binds user+workspace; tampered state rejected - callback never reassigns a store owned by a different workspace (cross-tenant protection verified) - require SHOPIFY_API_KEY/SHOPIFY_API_SECRET --- backend/integrations/shopify_routes.py | 211 +++++++++++++++++++++---- 1 file changed, 177 insertions(+), 34 deletions(-) diff --git a/backend/integrations/shopify_routes.py b/backend/integrations/shopify_routes.py index 5ab558403..caac7b8a4 100644 --- a/backend/integrations/shopify_routes.py +++ b/backend/integrations/shopify_routes.py @@ -1,7 +1,14 @@ from datetime import datetime +import hashlib +import hmac import logging +import os +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 @@ -16,33 +23,86 @@ # Auth Type: OAuth2 router = APIRouter(prefix="/api/shopify", tags=["shopify"]) + +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) -> str: + """Return a signed state token binding an authorize request to a user + workspace.""" + message = f"{user_id}|{workspace_id}" + sig = hmac.new(_shopify_state_secret().encode(), message.encode(), hashlib.sha256).hexdigest() + return f"{sig}.{user_id}.{workspace_id}" + + +def _verify_state(state: str) -> tuple: + """Verify a signed state token; returns (user_id, workspace_id) or raises 400.""" + if not state: + raise HTTPException(status_code=400, detail="Missing OAuth state") + try: + sig, user_id, workspace_id = state.split(".", 2) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid OAuth state") + expected = hmac.new(_shopify_state_secret().encode(), f"{user_id}|{workspace_id}".encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(sig, expected): + raise HTTPException(status_code=400, detail="Invalid OAuth state") + 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 -> localhost dev fallback.""" + 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("/") + return "http://localhost:8000" + + +def _callback_url() -> str: + return f"{_redirect_base_url()}/api/shopify/auth/callback" + @router.get("/auth/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. + """Get Shopify OAuth URL for a shop (authenticated). - Requires SHOPIFY_API_KEY / SHOPIFY_API_SECRET env vars (Azure-level app - credentials). Redirects back to /api/shopify/auth/callback after approval. + 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. """ - import os - client_id = os.getenv("SHOPIFY_API_KEY", "") - if not client_id: - raise HTTPException( - status_code=500, - detail="Shopify OAuth not configured. Please set SHOPIFY_API_KEY and SHOPIFY_API_SECRET environment variables." - ) + 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" - redirect_uri = "http://localhost:8000/api/shopify/auth/callback" + workspace_id = getattr(current_user, "workspace_id", None) or "default" params = { "client_id": client_id, "scope": scopes, - "redirect_uri": redirect_uri, - "state": str(current_user.id), + "redirect_uri": _callback_url(), + "state": _sign_state(str(current_user.id), workspace_id), } - from urllib.parse import urlencode url = f"https://{shop_url}/admin/oauth/authorize?{urlencode(params)}" return { "url": url, @@ -96,46 +156,129 @@ 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. + """ + # 1. Verify OAuth state -> recover the owning user + workspace (no client- + # supplied workspace is trusted; it comes from the signed state). + user_id, workspace_id = _verify_state(state) + + # 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") + 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) + + # 3. 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. tenant_id must equal the workspace id - # so the agent's shopify_* tools / action resolver (which scope by - # tenant_id == workspace_id) can find the connected store. + store = db.query(EcommerceStore).filter( EcommerceStore.shop_domain == auth_request.shop ).first() - tenant_id = auth_request.workspace_id or "default" - - 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", - tenant_id=tenant_id, - 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.tenant_id = tenant_id - 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") From e8cc37400d3409bd36f7d87f0fdb3eaf6ac8034c Mon Sep 17 00:00:00 2001 From: ichandrasharma Date: Fri, 21 Aug 2026 18:03:54 +0530 Subject: [PATCH 3/3] fix(shopify): one-time state + fail-closed callback base - OAuth state now binds user+workspace+shop with a random nonce and 10min expiry; blocks cross-shop replay of a previously issued state - state parsed on '|' separators so shop domains with dots don't break verification - _redirect_base_url fails closed (no loopback) unless SHOPIFY_DEV_LOOPBACK=1; public URL resolved from ATOM_PUBLIC_URL/ATOM_BASE_URL/PYTHON_BACKEND_URL/NEXT_PUBLIC_API_URL --- backend/integrations/shopify_routes.py | 84 ++++++++++++++++++++------ 1 file changed, 65 insertions(+), 19 deletions(-) diff --git a/backend/integrations/shopify_routes.py b/backend/integrations/shopify_routes.py index caac7b8a4..3d3145c3e 100644 --- a/backend/integrations/shopify_routes.py +++ b/backend/integrations/shopify_routes.py @@ -3,6 +3,8 @@ import hmac import logging import os +import secrets +import time from typing import Optional from urllib.parse import urlencode @@ -23,6 +25,10 @@ # 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).""" @@ -45,36 +51,68 @@ def _shopify_state_secret() -> str: return secret -def _sign_state(user_id: str, workspace_id: str) -> str: - """Return a signed state token binding an authorize request to a user + workspace.""" - message = f"{user_id}|{workspace_id}" - sig = hmac.new(_shopify_state_secret().encode(), message.encode(), hashlib.sha256).hexdigest() - return f"{sig}.{user_id}.{workspace_id}" +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. -def _verify_state(state: str) -> tuple: - """Verify a signed state token; returns (user_id, workspace_id) or raises 400.""" + 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") - try: - sig, user_id, workspace_id = state.split(".", 2) - except ValueError: + if "." not in state: raise HTTPException(status_code=400, detail="Invalid OAuth state") - expected = hmac.new(_shopify_state_secret().encode(), f"{user_id}|{workspace_id}".encode(), hashlib.sha256).hexdigest() + 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 -> localhost dev fallback.""" + 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("/") - return "http://localhost:8000" + 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: @@ -101,7 +139,7 @@ async def get_auth_url( "client_id": client_id, "scope": scopes, "redirect_uri": _callback_url(), - "state": _sign_state(str(current_user.id), workspace_id), + "state": _sign_state(str(current_user.id), workspace_id, shop_url), } url = f"https://{shop_url}/admin/oauth/authorize?{urlencode(params)}" return { @@ -171,15 +209,23 @@ async def shopify_auth_callback_get( persists the store under that verified workspace. Redirects the merchant's browser back to the frontend connect page on success/failure. """ - # 1. Verify OAuth state -> recover the owning user + workspace (no client- - # supplied workspace is trusted; it comes from the signed state). - user_id, workspace_id = _verify_state(state) - # 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: @@ -191,7 +237,7 @@ async def shopify_auth_callback_get( logger.error("Shopify callback: no access_token returned") return _redirect_connect(connected=False, shop=shop) - # 3. Persist the store, scoped to the verified workspace. Refuse to + # 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