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
7 changes: 6 additions & 1 deletion .github/workflows/SECRETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ Shopify Partner app**.
- Obtain the app at: https://partners.shopify.com → Apps → create app
- App URL: `https://sites-shopify.deco.site`
- Redirect URL: `https://sites-shopify.deco.site/oauth/shopify/callback`
- `SELF_URL` / `MESH_URL` are injected by the deco platform at runtime.
- **`SELF_URL`** (optional): this MCP's public origin. Defaults to
`https://sites-shopify.deco.site`; only set it to point OAuth at a different
host (e.g. `http://localhost:8001` for local dev).
- **`MESH_URL`** (optional): the mesh callback origin is validated against a
`decocms.com` allowlist (plus loopback) by default. Set `MESH_URL` to also
allow a self-hosted mesh origin — e.g. a local deco studio on a custom host.

### MCP: `github` (Cloudflare Workers — `deploy-github.yml`)
Unlike the other MCPs, github deploys directly via `wrangler deploy` in
Expand Down
30 changes: 27 additions & 3 deletions shopify/server/lib/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import { createHmac } from "node:crypto";

const SELF = "https://sites-shopify.deco.site";
const MESH = "https://mesh.example.com";
const MESH = "https://api.decocms.com";
const SECRET = "oauth-test-secret";
const CLIENT_ID = "test-client-id";
const CLIENT_SECRET = "test-client-secret";
Expand All @@ -27,7 +27,6 @@ beforeEach(() => {
process.env.SHOPIFY_CLIENT_SECRET = CLIENT_SECRET;
process.env.SHOPIFY_TOKEN_SECRET = SECRET;
process.env.SELF_URL = SELF;
process.env.MESH_URL = MESH;
});

afterEach(() => {
Expand All @@ -36,8 +35,8 @@ afterEach(() => {
delete process.env.SHOPIFY_CLIENT_SECRET;
delete process.env.SHOPIFY_TOKEN_SECRET;
delete process.env.SELF_URL;
delete process.env.MESH_URL;
delete process.env.SHOPIFY_SCOPES;
delete process.env.MESH_URL;
});

function shopifyHmac(params: Record<string, string>): string {
Expand All @@ -55,6 +54,12 @@ describe("authorizationUrl", () => {
expect(url.pathname).toBe(OAUTH_CONNECT_PATH);
expect(url.searchParams.get("callback_url")).toBe(CALLBACK);
});

test("defaults to the prod domain (not localhost) when SELF_URL is unset", () => {
delete process.env.SELF_URL;
const url = new URL(shopifyOAuth.authorizationUrl(CALLBACK));
expect(url.origin).toBe("https://sites-shopify.deco.site");
});
});

describe("GET /oauth/custom", () => {
Expand All @@ -78,6 +83,25 @@ describe("GET /oauth/custom", () => {
expect(res?.status).toBe(400);
});

test("MESH_URL allows a self-hosted mesh origin (e.g. local studio)", async () => {
const studio = "https://studio.internal/oauth/callback?state=s";
// Without the override this custom origin is rejected...
const rejected = await handleOAuthRoute(
new Request(
`${SELF}${OAUTH_CONNECT_PATH}?callback_url=${encodeURIComponent(studio)}`,
),
);
expect(rejected?.status).toBe(400);
// ...but setting MESH_URL to that origin allows it.
process.env.MESH_URL = "https://studio.internal";
const allowed = await handleOAuthRoute(
new Request(
`${SELF}${OAUTH_CONNECT_PATH}?callback_url=${encodeURIComponent(studio)}`,
),
);
expect(allowed?.status).toBe(200);
});

test("redirects to Shopify authorize once a shop is provided", async () => {
const res = await handleOAuthRoute(
new Request(
Expand Down
53 changes: 41 additions & 12 deletions shopify/server/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,15 @@ export function getScopes(): string {
return process.env.SHOPIFY_SCOPES?.trim() || DEFAULT_SCOPES;
}

/** This MCP's own public origin. Deployed at a fixed domain, so we default to
* it; override with SELF_URL for local dev (e.g. http://localhost:8001). */
const DEFAULT_SELF_URL = "https://sites-shopify.deco.site";

interface OAuthEnv {
clientId: string;
clientSecret: string;
tokenSecret: string;
selfUrl: string;
meshUrl: string;
}

/** Resolve OAuth config lazily (process.env isn't populated at module init on
Expand All @@ -91,8 +94,7 @@ function getOAuthEnv(): OAuthEnv {
clientId,
clientSecret,
tokenSecret,
selfUrl: process.env.SELF_URL || "http://localhost:8000",
meshUrl: process.env.MESH_URL || "http://localhost:3000",
selfUrl: process.env.SELF_URL || DEFAULT_SELF_URL,
};
}

Expand All @@ -102,17 +104,44 @@ function isValidShopDomain(shop: string): boolean {
return SHOP_DOMAIN_RE.test(shop);
}

/** Only ever redirect the OAuth code back to the mesh (or our own) origin. */
function isAllowedCallback(callbackUrl: string, env: OAuthEnv): boolean {
/** The deco mesh (which mints the OAuth callback URL) lives under decocms.com. */
const ALLOWED_CALLBACK_HOST_SUFFIXES = ["decocms.com"] as const;
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);

/**
* Guard against open-redirect / code theft: only ever hand the OAuth code back
* to the mesh's origin. The mesh callback lives under decocms.com; loopback is
* allowed over http for local dev (RFC 8252 §7.3), everything else must be
* https. Same model as the GitHub MCP's redirect allowlist.
*
* MESH_URL is an optional operator-controlled override: set it to allow a
* self-hosted mesh origin (e.g. a local deco studio on a custom host).
*/
function isAllowedCallback(callbackUrl: string): boolean {
let url: URL;
try {
const origin = new URL(callbackUrl).origin;
return (
origin === new URL(env.meshUrl).origin ||
origin === new URL(env.selfUrl).origin
);
url = new URL(callbackUrl);
} catch {
return false;
}

// Explicit override for a self-hosted / local mesh (trusted, operator-set).
const meshUrl = process.env.MESH_URL;
if (meshUrl) {
try {
if (url.origin === new URL(meshUrl).origin) return true;
} catch {
// fall through to the default allowlist
}
}

const host = url.hostname.toLowerCase();
const isLoopback = LOOPBACK_HOSTS.has(host);
if (url.protocol === "http:" && isLoopback) return true;
if (url.protocol !== "https:") return false;
return ALLOWED_CALLBACK_HOST_SUFFIXES.some(
(suffix) => host === suffix || host.endsWith(`.${suffix}`),
);
}

// ── Runtime oauth hook ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -188,7 +217,7 @@ function connectForm(callbackUrl: string): Response {
* into Shopify's authorize endpoint. */
function handleConnect(url: URL, env: OAuthEnv): Response {
const callbackUrl = url.searchParams.get("callback_url");
if (!callbackUrl || !isAllowedCallback(callbackUrl, env)) {
if (!callbackUrl || !isAllowedCallback(callbackUrl)) {
return htmlResponse("Invalid or missing callback URL.", 400);
}

Expand Down Expand Up @@ -225,7 +254,7 @@ async function handleCallback(url: URL, env: OAuthEnv): Promise<Response> {
params.get("state") ?? "",
env.tokenSecret,
);
if (!state?.cb || !isAllowedCallback(state.cb, env)) {
if (!state?.cb || !isAllowedCallback(state.cb)) {
return htmlResponse("Invalid OAuth state.", 400);
}

Expand Down
Loading