From f7817373d8a26a6029a533ca55b923b9ff73fac3 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:18:14 +0900 Subject: [PATCH] fix(oauth): require dashboard consent for Muse import --- src/server/management/oauth-account-routes.ts | 13 ++++++++- tests/oauth/oauth-public-surface.test.ts | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 4cd52004665..919e95e064e 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -148,7 +148,7 @@ function validateKeyName( } export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, deps, principal, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/accounts/events" && req.method === "GET") { const { accountSelectionStream } = await import("./account-selection-stream"); @@ -172,6 +172,17 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean; openBrowser?: unknown }; const provider = (body.provider ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); + // Meta Muse login imports a credential from the user's macOS Keychain and + // persists it in OpenCodex. A raw management token proves administrative + // access, not that a person acknowledged that credential move and its ToS + // risk. The dashboard warning therefore needs this matching server-side gate; + // headers are not evidence because an admin-token holder can forge them. + if (provider === "meta-muse" && principal !== "gui-session") { + return jsonResponse({ + error: "Meta Muse import requires acknowledgement in the OpenCodex dashboard.", + code: "oauth_consent_required", + }, 403); + } const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); if (namespaceCollision) return jsonResponse({ error: namespaceCollision }, 409); const accountId = body.accountId?.trim(); diff --git a/tests/oauth/oauth-public-surface.test.ts b/tests/oauth/oauth-public-surface.test.ts index fd9bb74a9eb..43938027af3 100644 --- a/tests/oauth/oauth-public-surface.test.ts +++ b/tests/oauth/oauth-public-surface.test.ts @@ -84,6 +84,35 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { expect(isPublicOAuthProvider("github-copilot")).toBe(true); }); + test("Meta Muse import requires a consent-bearing GUI session", async () => { + const cfg = config(); + const request = () => new Request("http://localhost/api/oauth/login", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "http://localhost", + "x-opencodex-gui-origin": "http://localhost", + "x-opencodex-csrf-token": "forgeable-without-a-session", + }, + // A missing account makes a correctly admitted request stop before the + // platform-specific import, while still proving it passed the consent gate. + body: JSON.stringify({ provider: "meta-muse", accountId: "missing-slot" }), + }); + + for (const principal of [undefined, "admin-token", "gui-pair-capability"] as const) { + const response = await handleManagementAPI(request(), new URL(request().url), cfg, {}, principal); + expect(response?.status).toBe(403); + expect(await response?.json()).toEqual({ + error: "Meta Muse import requires acknowledgement in the OpenCodex dashboard.", + code: "oauth_consent_required", + }); + } + + const admitted = await handleManagementAPI(request(), new URL(request().url), cfg, {}, "gui-session"); + expect(admitted?.status).toBe(404); + expect(await admitted?.json()).toEqual({ error: "Unknown account for reauth" }); + }); + test("generic management OAuth endpoints reject chatgpt before touching login state", async () => { const cfg = config(); const requests = [