mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
committed by
GitHub
parent
0a0fdad001
commit
c2bf8d5492
@@ -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`);
|
||||
|
||||
1
changelog.d/fixes/9474-claude-code-oauth-mismap.md
Normal file
1
changelog.d/fixes/9474-claude-code-oauth-mismap.md
Normal 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)
|
||||
109
tests/unit/9474-claude-code-oauth-mismap.test.ts
Normal file
109
tests/unit/9474-claude-code-oauth-mismap.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user