From 48ce09d5446f93618fac1a8da1cf6c02a9117478 Mon Sep 17 00:00:00 2001 From: Arlieeee Date: Mon, 21 Sep 2026 18:07:16 +0800 Subject: [PATCH 1/8] [Update] Brand CLI OAuth callback pages (ENG-3406) --- src/internal/oauth-page.ts | 71 ++++++++++++++++++++++++++++++++++++++ src/internal/oauth.ts | 50 ++++++--------------------- tests/oauth.test.ts | 45 ++++++++++++++++++++---- 3 files changed, 120 insertions(+), 46 deletions(-) create mode 100644 src/internal/oauth-page.ts diff --git a/src/internal/oauth-page.ts b/src/internal/oauth-page.ts new file mode 100644 index 0000000..afcf17d --- /dev/null +++ b/src/internal/oauth-page.ts @@ -0,0 +1,71 @@ +type CallbackPageStatus = "authorized" | "canceled" | "error"; + +// Official assets/brand/meshy-wordmark-64.svg from meshy-webapp. Inlined so +// loopback pages work offline and never send callback URLs to an asset host. +const WORDMARK = ""; + +const COPY = { + en: { + authorized: "Meshy CLI authorized", + canceled: "Connection canceled", + error: "Unable to connect", + next: "Return to your terminal to continue. You can close this tab.", + canceledNext: "No access was granted. You can close this tab.", + retry: "Return to your terminal and try signing in again.", + details: "Error details", + back: "Back to Meshy", + }, + zh: { + authorized: "已授权 Meshy CLI", + canceled: "连接已取消", + error: "无法完成连接", + next: "请回到终端继续操作。此页面可以关闭。", + canceledNext: "未授予访问权限。此页面可以关闭。", + retry: "请回到终端,重新发起登录。", + details: "错误详情", + back: "返回 Meshy", + }, +}; + +function escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + +export function renderCallbackPage(status: CallbackPageStatus, message = "", acceptLanguage = ""): string { + const locale = /^zh\b/i.test(acceptLanguage.trim()) ? "zh" : "en"; + const copy = COPY[locale]; + const title = copy[status]; + const description = status === "authorized" ? copy.next : status === "canceled" ? copy.canceledNext : copy.retry; + const glyph = status === "authorized" ? '' : ''; + return ` + + + + + +Meshy — ${title} + + +
+ +
+ +

${title}

${description}

+${status === "error" ? 'meshy auth login' : ""} +${status === "error" && message ? `
${copy.details}

${escapeHtml(message)}

` : ""} +${copy.back} +
`; +} diff --git a/src/internal/oauth.ts b/src/internal/oauth.ts index 323e22d..66a7304 100644 --- a/src/internal/oauth.ts +++ b/src/internal/oauth.ts @@ -19,6 +19,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import { spawn } from "node:child_process"; import { HintedError } from "./errors.js"; import { USER_AGENT } from "./user-agent.js"; +import { renderCallbackPage } from "./oauth-page.js"; // --------------------------------------------------------------------------- // PKCE helpers @@ -65,20 +66,6 @@ export function buildAuthorizeUrl(params: BuildAuthorizeUrlParams): string { return url.toString(); } -// --------------------------------------------------------------------------- -// HTML helpers -// --------------------------------------------------------------------------- - -/** Escape characters that are special in HTML to prevent reflected XSS. */ -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - // --------------------------------------------------------------------------- // Loopback callback server // --------------------------------------------------------------------------- @@ -96,27 +83,6 @@ export interface CallbackServer { const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes -const SUCCESS_HTML = ` - -Meshy — Login successful - -

Login successful

-

You can close this tab and return to the terminal.

- -`; - -function errorHtml(msg: string): string { - return ` - -Meshy — Login failed - -

Login failed

-

${escapeHtml(msg)}

-

Return to the terminal for details.

- -`; -} - /** * Starts a loopback HTTP server bound to 127.0.0.1 only. * @@ -165,6 +131,11 @@ export function startCallbackServer( return; } + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Referrer-Policy", "no-referrer"); + res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"); + const language = req.headers["accept-language"]; + const code = url.searchParams.get("code") ?? undefined; const state = url.searchParams.get("state") ?? undefined; const error = url.searchParams.get("error") ?? undefined; @@ -173,7 +144,7 @@ export function startCallbackServer( if (error) { const msg = errorDescription ?? error; res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); - res.end(errorHtml(msg)); + res.end(renderCallbackPage(error === "access_denied" ? "canceled" : "error", msg, language)); settle(new HintedError({ message: `Authorization denied: ${msg}`, code: "oauth_denied", @@ -185,7 +156,7 @@ export function startCallbackServer( // State verification: reject mismatches before showing any success page. if (state !== expectedState) { res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); - res.end(errorHtml("Login failed: state mismatch — you can close this tab and retry.")); + res.end(renderCallbackPage("error", "OAuth state mismatch. Start a new login from your terminal.", language)); settle(new HintedError({ message: "OAuth state mismatch — possible CSRF attack. Run: meshy auth login", code: "oauth_state_mismatch", @@ -200,7 +171,7 @@ export function startCallbackServer( // the user while the terminal fails with oauth_no_code. if (!code) { res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); - res.end(errorHtml("Login failed: no authorization code received — you can close this tab and retry.")); + res.end(renderCallbackPage("error", "No authorization code received. Start a new login from your terminal.", language)); settle(new HintedError({ message: "No authorization code received from the callback.", code: "oauth_no_code", @@ -210,7 +181,8 @@ export function startCallbackServer( } res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - res.end(SUCCESS_HTML); + // Receiving a code precedes token exchange and credential persistence. + res.end(renderCallbackPage("authorized", "", language)); settle({ code, state }); }); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 961b4f0..f983bd4 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -179,12 +179,43 @@ test("callback server — success page on valid /callback with correct state", a const res = await fetch(`http://127.0.0.1:${port}/callback?code=mycode&state=${encodeURIComponent(state)}`); assert.equal(res.status, 200); const body = await res.text(); - assert.ok(body.includes("Login successful")); + assert.ok(body.includes("Meshy CLI authorized")); + assert.ok(!body.includes("Login successful"), "token exchange has not completed yet"); + assert.ok(!body.includes("mycode"), "authorization codes must not be reflected into the page"); + assert.equal(res.headers.get("cache-control"), "no-store"); + assert.equal(res.headers.get("referrer-policy"), "no-referrer"); const result = await waitForCallback; assert.equal(result.code, "mycode"); assert.equal(result.state, state); }); +test("callback page — Chinese UI follows the browser language", async () => { + const { port, waitForCallback } = await startCallbackServer(0, "language-state"); + const res = await fetch(`http://127.0.0.1:${port}/callback?code=example&state=language-state`, { + headers: { "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" }, + }); + const body = await res.text(); + assert.match(body, /lang="zh"/); + assert.match(body, /已授权 Meshy CLI/); + assert.match(body, /请回到终端继续操作/); + await waitForCallback; +}); + +test("callback page — error details cannot inject markup or load third-party assets", async () => { + const { port, waitForCallback } = await startCallbackServer(0, "error-state"); + const rejected = assert.rejects(waitForCallback); + const res = await hitCallback(port, { + error: "server_error", + error_description: '', + }); + const body = await res.text(); + assert.ok(body.includes("<img")); + assert.ok(!body.includes(" { const state = generateState(); const { port, waitForCallback } = await startCallbackServer(0, state); @@ -223,9 +254,9 @@ test("callback server — state tampering: wrong state → 400 response, promise assert.equal(res.status, 400, "state mismatch must return 400, not 200"); const body = await res.text(); // Must NOT show the success page. - assert.ok(!body.includes("Login successful"), "must not show success page on state mismatch"); + assert.ok(!body.includes("Meshy CLI authorized"), "must not show success page on state mismatch"); // Must show an error indication. - assert.ok(body.includes("state mismatch") || body.includes("Login failed"), "must show error on state mismatch"); + assert.ok(body.includes("state mismatch") || body.includes("Unable to connect"), "must show error on state mismatch"); await rejectionPromise; }); @@ -279,8 +310,8 @@ test("callback server — correct state but no code → 400, rejection, no succe ); assert.equal(res.status, 400, "missing code must return 400"); const body = await res.text(); - assert.ok(!body.includes("Login successful"), "must not show success page when code is missing"); - assert.ok(body.includes("Login failed") || body.includes("no authorization code"), "must show error page"); + assert.ok(!body.includes("Meshy CLI authorized"), "must not show success page when code is missing"); + assert.ok(body.includes("Unable to connect") || body.includes("no authorization code"), "must show error page"); await rejectionPromise; }); @@ -304,8 +335,8 @@ test("callback server — correct state but empty code (&code=) → 400, rejecti ); assert.equal(res.status, 400, "empty code must return 400"); const body = await res.text(); - assert.ok(!body.includes("Login successful"), "must not show success page when code is empty"); - assert.ok(body.includes("Login failed") || body.includes("no authorization code"), "must show error page"); + assert.ok(!body.includes("Meshy CLI authorized"), "must not show success page when code is empty"); + assert.ok(body.includes("Unable to connect") || body.includes("no authorization code"), "must show error page"); await rejectionPromise; }); From 36229955f52d1b5e910630e8b59ebd18ef7f6100 Mon Sep 17 00:00:00 2001 From: Arlieeee Date: Tue, 22 Sep 2026 14:44:16 +0800 Subject: [PATCH 2/8] test(oauth): cover the canceled callback page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit access_denied now renders its own "Connection canceled" page instead of the generic error page, and that branch had no assertion. Also pin down that a canceled page carries no error details — the cancel is deliberate, so there is nothing to report back to the user. Co-Authored-By: Claude Opus 5 (1M context) --- tests/oauth.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index f983bd4..14f7d7d 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -229,7 +229,12 @@ test("callback server — ?error=access_denied rejects with that error", async ( }, ); // Now drive the callback with an error. - await fetch(`http://127.0.0.1:${port}/callback?error=access_denied&error_description=User+denied`); + const res = await fetch(`http://127.0.0.1:${port}/callback?error=access_denied&error_description=User+denied`); + const body = await res.text(); + // access_denied is a deliberate cancel, not a failure: it gets its own page. + assert.ok(body.includes("Connection canceled")); + assert.ok(!body.includes("Unable to connect")); + assert.ok(!body.includes("User denied"), "canceled pages carry no error details"); await rejectionPromise; }); From 9093526a90fbcb303f749eb5a1e9de790b25618c Mon Sep 17 00:00:00 2001 From: Arlieeee Date: Tue, 22 Sep 2026 16:17:19 +0800 Subject: [PATCH 3/8] fix(oauth): match the callback page to the polished consent screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consent page lost its native ▶ disclosure marker, gained a card top highlight and a tighter backdrop. The loopback page is meant to read as the same surface, so it gets the same four changes: marker off with a CSS-drawn chevron that turns on open, inset top highlight on the card, a narrower top wash, and a radial grid mask centred on the card instead of a linear fade that left a seam mid-page. Still self-contained: the chevron is drawn with borders, so no icon font, no extra markup and nothing new to fetch. Co-Authored-By: Claude Opus 5 (1M context) --- src/internal/oauth-page.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/internal/oauth-page.ts b/src/internal/oauth-page.ts index afcf17d..45a2855 100644 --- a/src/internal/oauth-page.ts +++ b/src/internal/oauth-page.ts @@ -47,15 +47,15 @@ export function renderCallbackPage(status: CallbackPageStatus, message = "", acc Meshy — ${title} From 5089905363dd9474375eb96b29c9d07b8c16c677 Mon Sep 17 00:00:00 2001 From: Arlieeee Date: Tue, 22 Sep 2026 16:58:54 +0800 Subject: [PATCH 4/8] fix(oauth): restore the callback page backdrop Follows the consent page back to its original backdrop: the wide top wash and the plain top-to-bottom grid fade. The card-centred variant was tried and rejected. The chevron disclosure and the card's top highlight stay. Co-Authored-By: Claude Opus 5 (1M context) --- src/internal/oauth-page.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/internal/oauth-page.ts b/src/internal/oauth-page.ts index 45a2855..5f994f4 100644 --- a/src/internal/oauth-page.ts +++ b/src/internal/oauth-page.ts @@ -47,8 +47,8 @@ export function renderCallbackPage(status: CallbackPageStatus, message = "", acc Meshy — ${title}