From 58c5412fd016786315574a8e4ada99b6fd623980 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Wed, 9 Sep 2026 13:22:40 +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 874e3e24fa..e1a07f05ec 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -127,7 +127,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"); @@ -151,6 +151,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 fd9bb74a9e..43938027af 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 = [