From c2bf8d54920c8b2e7cf889f677dcd9ed693c172a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 5 Aug 2026 16:49:51 -0300 Subject: [PATCH] fix(cli): route claude-code OAuth to the Anthropic claude browser-PKCE flow instead of the unrelated command-code provider (#9474) Closes #9474 --- bin/cli/commands/oauth.mjs | 132 +++++++++++++++--- .../fixes/9474-claude-code-oauth-mismap.md | 1 + .../9474-claude-code-oauth-mismap.test.ts | 109 +++++++++++++++ 3 files changed, 223 insertions(+), 19 deletions(-) create mode 100644 changelog.d/fixes/9474-claude-code-oauth-mismap.md create mode 100644 tests/unit/9474-claude-code-oauth-mismap.test.ts diff --git a/bin/cli/commands/oauth.mjs b/bin/cli/commands/oauth.mjs index 9bcbc92d6c..d1d2fd3b40 100644 --- a/bin/cli/commands/oauth.mjs +++ b/bin/cli/commands/oauth.mjs @@ -10,11 +10,28 @@ const PROVIDERS_WITH_OAUTH = [ { id: "cursor", name: "Cursor", flow: "import" }, { id: "zed", name: "Zed", flow: "import" }, { id: "kiro", name: "Amazon Kiro", flow: "social" }, - { id: "claude-code", name: "Claude Code (OAuth)", flow: "device" }, + { id: "claude-code", name: "Claude Code (OAuth)", flow: "browser" }, { id: "codex", name: "OpenAI Codex (OAuth)", flow: "device" }, { id: "copilot", name: "GitHub Copilot", flow: "device" }, ]; +// The user-facing provider id (the one shown by `omniroute oauth providers`) +// is NOT always the backend OAuth provider key the server's /api/oauth/[provider]/... +// route expects. `claude-code` is the CLI-facing alias for Anthropic's Claude +// OAuth, which the server registers under the key `claude` (see +// src/lib/oauth/providers/index.ts). Routing `claude-code` to the unrelated +// `command-code` (CommandCode.ai) provider — as the previous code did — sent +// the device-flow request to /api/providers/command-code/auth/start, which is +// gated by requireManagementAuth and returned 401 for a fresh CLI context +// (issue #9474). Map the alias to the real backend key instead. +const BACKEND_OAUTH_KEY = { + "claude-code": "claude", +}; + +function resolveBackendKey(id) { + return BACKEND_OAUTH_KEY[id] ?? id; +} + const oauthProviderSchema = [ { key: "id", header: "Provider ID", width: 16 }, { key: "name", header: "Name", width: 28 }, @@ -56,34 +73,111 @@ async function pollStatus(endpoint, timeoutMs) { } async function runBrowserFlow(def, opts) { - const startRes = await apiFetch(`/api/oauth/${def.id}/start`, { method: "POST" }); + // The user-facing id (`def.id`, e.g. "claude-code") must be translated to the + // backend OAuth provider key the server's /api/oauth/[provider]/... route + // expects (e.g. "claude"). The previous implementation called a non-existent + // `/api/oauth/${def.id}/start` action — no such action exists on the server + // (src/app/api/oauth/[provider]/[action]/route.ts), so the browser flow was + // broken for every browser-flow provider. Use the real `authorize` action and + // complete the PKCE (authorization_code / authorization_code_pkce) flow with a + // manual code paste, mirroring the dashboard's manual "input" step. + const backendKey = resolveBackendKey(def.id); + const redirectUri = opts.redirectUri ?? null; + const authorizeUrl = `/api/oauth/${backendKey}/authorize${ + redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : "" + }`; + const startRes = await apiFetch(authorizeUrl, { method: "GET" }); if (!startRes.ok) { - process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}\n`); + const detail = await safeErrorBody(startRes); + process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}${detail}\n`); process.exit(1); } const start = await startRes.json(); - const url = start.authorizeUrl ?? start.url; + const url = start.authUrl ?? start.authorizeUrl ?? start.url; + if (!url) { + const hint = start.error ?? "no authUrl returned by the server"; + process.stderr.write(`OAuth unavailable for ${def.id}: ${hint}\n`); + process.exit(1); + } + const { codeVerifier, state, redirectUri: returnedRedirectUri } = start; + const finalRedirectUri = returnedRedirectUri || redirectUri; - if (process.stdout.isTTY && opts.browser !== false) { - const { startOAuthTui } = await import("../tui/OAuthFlow.jsx"); - await openBrowser(url); - const tuiResult = await startOAuthTui({ provider: def.name ?? def.id, url }); - if (tuiResult.status === "cancelled") return; - } else { - process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`); - if (opts.browser !== false) await openBrowser(url); - process.stderr.write("Waiting for authorization... (Ctrl+C to cancel)\n"); + process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`); + if (opts.browser !== false) await openBrowser(url); + process.stdout.write( + "After authorizing, paste the callback URL (or the Authentication Code\n" + + "shown on the confirmation page) here:\n" + ); + + const { createPrompt } = await import("../io.mjs"); + const prompt = createPrompt(); + const input = await prompt.ask("Callback URL or code"); + prompt.close(); + + const trimmed = input.trim(); + if (!trimmed) { + process.stderr.write("No authorization code provided.\n"); + process.exit(1); } - const result = await pollStatus( - `/api/oauth/${def.id}/status?state=${encodeURIComponent(start.state ?? "")}`, - opts.timeout ?? 300000 - ); + // The Anthropic Claude confirmation page (platform.claude.com/oauth/code/callback) + // shows a raw "Authentication Code" like `code#state` rather than a full URL. + // The dashboard's manual submit (src/shared/components/OAuthModal.tsx) parses + // both forms; mirror that here. + let code = null; + let codeState = state || null; + try { + const cbUrl = new URL(trimmed); + code = cbUrl.searchParams.get("code"); + const stateParam = cbUrl.searchParams.get("state") || cbUrl.hash.replace(/^#/, ""); + if (stateParam) codeState = stateParam; + } catch { + const [rawCode, rawState] = trimmed.split("#", 2); + code = rawCode || null; + if (rawState) codeState = rawState; + } + if (!code) { + process.stderr.write( + "No authorization code found. Paste the callback URL or the Authentication Code.\n" + ); + process.exit(1); + } + + const exchangeRes = await apiFetch(`/api/oauth/${backendKey}/exchange`, { + method: "POST", + body: { + code, + redirectUri: finalRedirectUri, + codeVerifier, + ...(codeState ? { state: codeState } : {}), + }, + }); + if (!exchangeRes.ok) { + const detail = await safeErrorBody(exchangeRes); + process.stderr.write(`Token exchange failed: ${exchangeRes.status}${detail}\n`); + process.exit(1); + } + const result = await exchangeRes.json(); + const conn = result.connection ?? {}; process.stdout.write( - `Authorized: ${result.email ?? result.userId ?? result.account ?? "connected"}\n` + `Authorized: ${conn.email ?? conn.displayName ?? conn.id ?? "connected"}\n` ); } +async function safeErrorBody(res) { + try { + const data = await res.json(); + if (data?.error) { + const msg = typeof data.error === "string" ? data.error : data.error?.message; + if (msg) return `: ${msg}`; + } + if (data?.message) return `: ${data.message}`; + } catch { + /* ignore */ + } + return ""; +} + async function runImportFlow(def, opts) { const endpoint = opts.importFromSystem ? `/api/oauth/${def.id}/auto-import` @@ -124,7 +218,7 @@ async function runSocialFlow(def, opts) { } async function runDeviceFlow(def, opts) { - const providerKey = def.id === "claude-code" ? "command-code" : def.id; + const providerKey = resolveBackendKey(def.id); const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, { method: "POST" }); if (!startRes.ok) { process.stderr.write(`Failed to start device flow: ${startRes.status}\n`); diff --git a/changelog.d/fixes/9474-claude-code-oauth-mismap.md b/changelog.d/fixes/9474-claude-code-oauth-mismap.md new file mode 100644 index 0000000000..4a7df44a69 --- /dev/null +++ b/changelog.d/fixes/9474-claude-code-oauth-mismap.md @@ -0,0 +1 @@ +- fix(cli): route claude-code OAuth to the Anthropic `claude` browser-PKCE flow instead of the unrelated command-code provider (#9474) diff --git a/tests/unit/9474-claude-code-oauth-mismap.test.ts b/tests/unit/9474-claude-code-oauth-mismap.test.ts new file mode 100644 index 0000000000..310cf81433 --- /dev/null +++ b/tests/unit/9474-claude-code-oauth-mismap.test.ts @@ -0,0 +1,109 @@ +// Repro/regression test for issue #9474 +// Claude Code OAuth device flow (`omniroute oauth start --provider claude-code`) +// failed with 401 because the CLI mapped `claude-code` to the unrelated +// `command-code` (CommandCode.ai) API-key provider instead of the real +// Anthropic `claude` browser-PKCE OAuth flow. +// +// This test asserts the FIXED behavior: +// - `claude-code` is labeled `flow: "browser"` (not `"device"`) +// - `runDeviceFlow` no longer remaps `claude-code` to `command-code` +// - the CLI resolves the user-facing `claude-code` id to the backend +// OAuth provider key `claude` and calls the existing browser-PKCE +// actions (`authorize` / `exchange`), never `command-code`. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, "..", ".."); +const oauthCliPath = join(repoRoot, "bin/cli/commands/oauth.mjs"); +const oauthCli = readFileSync(oauthCliPath, "utf8"); + +test("#9474: claude-code is advertised as a browser flow (not device)", () => { + // The user-facing entry must be flow: "browser" — Anthropic Claude OAuth is + // authorization_code_pkce (browser), not a device-code flow. + assert.match( + oauthCli, + /\{\s*id:\s*"claude-code",\s*name:\s*"Claude Code \(OAuth\)",\s*flow:\s*"browser"\s*\}/, + "claude-code must be labeled flow: \"browser\" (Anthropic uses a browser PKCE flow)" + ); + // And it must NOT be labeled device. + assert.doesNotMatch( + oauthCli, + /\{\s*id:\s*"claude-code",\s*name:\s*"Claude Code \(OAuth\)",\s*flow:\s*"device"\s*\}/, + "claude-code must not be labeled flow: \"device\"" + ); +}); + +test("#9474: runDeviceFlow no longer remaps claude-code -> command-code", () => { + // The mismap line must be gone entirely. + assert.doesNotMatch( + oauthCli, + /claude-code"\s*\?\s*"command-code"/, + "the claude-code -> command-code remap in runDeviceFlow must be removed" + ); + // And runDeviceFlow must not call the command-code provider route via apiFetch. + // (Comments explaining the historical bug may mention the path; only an actual + // apiFetch call to it is a regression.) + assert.doesNotMatch( + oauthCli, + /apiFetch\(\s*`\/api\/providers\/command-code\/auth\/start/, + "runDeviceFlow must not apiFetch /api/providers/command-code/auth/start" + ); +}); + +test("#9474: CLI resolves user-facing claude-code to backend key claude", () => { + // The CLI must map the user-facing id `claude-code` to the backend OAuth + // provider key `claude` (the key /api/oauth/[provider]/... expects). + // Look for a resolution helper that produces "claude" for "claude-code". + assert.match( + oauthCli, + /claude-code"\s*,?\s*.*?"claude"/, + "claude-code must resolve to backend OAuth key claude" + ); +}); + +test("#9474: browser flow for claude-code targets /api/oauth/claude/authorize (not command-code, not a non-existent /start)", () => { + // The fixed browser flow must call the existing server action `authorize` + // on the resolved backend key `claude` — not the non-existent `/start` + // action, and not the command-code provider route. + // The runBrowserFlow helper must use the resolved backend key, not def.id, + // so claude-code routes to /api/oauth/claude/... . + assert.match( + oauthCli, + /\/api\/oauth\/\$\{[^}]*backendKey[^}]*\}\/authorize/, + "runBrowserFlow must call /api/oauth/${backendKey}/authorize using the resolved backend key" + ); + assert.match( + oauthCli, + /\/api\/oauth\/\$\{[^}]*backendKey[^}]*\}\/exchange/, + "runBrowserFlow must call /api/oauth/${backendKey}/exchange using the resolved backend key" + ); + // The old broken non-existent `/start` action must be gone from runBrowserFlow. + // Comments explaining the historical bug may mention the path; only an actual + // apiFetch call to it is a regression. + assert.doesNotMatch( + oauthCli, + /apiFetch\(\s*`\/api\/oauth\/\$\{def\.id\}\/start/, + "runBrowserFlow must not apiFetch the non-existent /api/oauth/${def.id}/start action" + ); +}); + +test("#9474: real Anthropic Claude OAuth is provider `claude` with browser PKCE flow (not device)", async () => { + const mod = await import("../../src/lib/oauth/providers/claude.ts"); + const claude = mod.claude; + assert.equal(claude.flowType, "authorization_code_pkce"); + assert.notEqual(claude.flowType, "device_code"); + assert.equal(claude.config.authorizeUrl, "https://claude.ai/oauth/authorize"); +}); + +test("#9474: command-code provider is the unrelated CommandCode.ai apikey provider (unchanged, sanity)", async () => { + const mod = await import("../../open-sse/config/providers/registry/command-code/index.ts"); + const commandCodeProvider = mod.command_codeProvider; + assert.equal(commandCodeProvider.id, "command-code"); + assert.equal(commandCodeProvider.baseUrl, "https://api.commandcode.ai"); + // command-code must remain distinct from the Anthropic claude provider. + assert.notEqual(commandCodeProvider.id, "claude"); +});