fix(cli): route claude-code OAuth to the Anthropic claude browser-PKCE flow instead of the unrelated command-code provider (#9474)

Closes #9474
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-05 16:49:51 -03:00
committed by GitHub
parent 0a0fdad001
commit c2bf8d5492
3 changed files with 223 additions and 19 deletions

View File

@@ -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`);

View File

@@ -0,0 +1 @@
- fix(cli): route claude-code OAuth to the Anthropic `claude` browser-PKCE flow instead of the unrelated command-code provider (#9474)

View File

@@ -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");
});