fix(ai): improve anthropic oauth flow fixes #2119

This commit is contained in:
Mario Zechner
2026-03-13 22:26:28 +01:00
parent 4b9e6006fa
commit 92882dc4cc
4 changed files with 460 additions and 105 deletions

40
package-lock.json generated
View File

@@ -63,26 +63,6 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.73.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz",
"integrity": "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
@@ -8581,6 +8561,26 @@
"node": ">=20.0.0"
}
},
"packages/ai/node_modules/@anthropic-ai/sdk": {
"version": "0.73.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz",
"integrity": "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"packages/ai/node_modules/@types/node": {
"version": "24.12.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",

View File

@@ -128,6 +128,38 @@ describe("agentLoop with AgentMessage", () => {
expect(eventTypes).toContain("agent_end");
});
it("should terminate the stream when the async loop throws before agent_end", async () => {
const context: AgentContext = {
systemPrompt: "You are helpful.",
messages: [],
tools: [],
};
const userPrompt: AgentMessage = createUserMessage("Hello");
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
};
const events: AgentEvent[] = [];
const stream = agentLoop([userPrompt], context, config, undefined, async () => {
throw new Error("boom");
});
const result = await Promise.race([
(async () => {
for await (const event of stream) {
events.push(event);
}
return "completed" as const;
})(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 50)),
]);
expect(result).toBe("timeout");
expect(events.map((e) => e.type)).not.toContain("agent_end");
});
it("should handle custom message types via convertToLlm", async () => {
// Create a custom message type
interface CustomNotification {

View File

@@ -62,7 +62,7 @@ function getCacheControl(
}
// Stealth mode: Mimic Claude Code's tool naming exactly
const claudeCodeVersion = "2.1.62";
const claudeCodeVersion = "2.1.75";
// Claude Code 2.x tool names (canonical casing)
// Source: https://cchistory.mariozechner.at/data/prompts-2.1.11.md

View File

@@ -1,114 +1,434 @@
/**
* Anthropic OAuth flow (Claude Pro/Max)
*
* NOTE: This module uses Node.js http.createServer and child_process for the OAuth callback
* and token exchange. It is only intended for CLI use, not browser environments.
*/
import type { Server } from "node:http";
import { generatePKCE } from "./pkce.js";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js";
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.js";
type CallbackServerInfo = {
server: Server;
redirectUri: string;
cancelWait: () => void;
waitForCode: () => Promise<{ code: string; state: string } | null>;
};
type NodeApis = {
createServer: typeof import("node:http").createServer;
execFile: typeof import("node:child_process").execFile;
};
let nodeApis: NodeApis | null = null;
let nodeApisPromise: Promise<NodeApis> | null = null;
const decode = (s: string) => atob(s);
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";
const SCOPES = "org:create_api_key user:profile user:inference";
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
const MANUAL_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback";
const CALLBACK_HOST = "127.0.0.1";
const CALLBACK_PORT = 53692;
const CALLBACK_PATH = "/callback";
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
const SCOPES =
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
const SUCCESS_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Authentication successful</title>
</head>
<body>
<p>Authentication successful. Return to your terminal to continue.</p>
</body>
</html>`;
/**
* Login with Anthropic OAuth (device code flow)
*
* @param onAuthUrl - Callback to handle the authorization URL (e.g., open browser)
* @param onPromptCode - Callback to prompt user for the authorization code
*/
export async function loginAnthropic(
onAuthUrl: (url: string) => void,
onPromptCode: () => Promise<string>,
): Promise<OAuthCredentials> {
const { verifier, challenge } = await generatePKCE();
async function getNodeApis(): Promise<NodeApis> {
if (nodeApis) return nodeApis;
if (!nodeApisPromise) {
if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) {
throw new Error("Anthropic OAuth is only available in Node.js environments");
}
nodeApisPromise = Promise.all([import("node:http"), import("node:child_process")]).then(
([httpModule, childProcessModule]) => ({
createServer: httpModule.createServer,
execFile: childProcessModule.execFile,
}),
);
}
nodeApis = await nodeApisPromise;
return nodeApis;
}
// Build authorization URL
const authParams = new URLSearchParams({
code: "true",
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES,
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
});
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
const value = input.trim();
if (!value) return {};
const authUrl = `${AUTHORIZE_URL}?${authParams.toString()}`;
// Notify caller with URL to open
onAuthUrl(authUrl);
// Wait for user to paste authorization code (format: code#state)
const authCode = await onPromptCode();
const splits = authCode.split("#");
const code = splits[0];
const state = splits[1];
// Exchange code for tokens
const tokenResponse = await fetch(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code: code,
state: state,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
}),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
throw new Error(`Token exchange failed: ${error}`);
try {
const url = new URL(value);
return {
code: url.searchParams.get("code") ?? undefined,
state: url.searchParams.get("state") ?? undefined,
};
} catch {
// not a URL
}
const tokenData = (await tokenResponse.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (value.includes("#")) {
const [code, state] = value.split("#", 2);
return { code, state };
}
// Calculate expiry time (current time + expires_in seconds - 5 min buffer)
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
if (value.includes("code=")) {
const params = new URLSearchParams(value);
return {
code: params.get("code") ?? undefined,
state: params.get("state") ?? undefined,
};
}
return { code: value };
}
function formatErrorDetails(error: unknown): string {
if (error instanceof Error) {
const details: string[] = [`${error.name}: ${error.message}`];
const errorWithCode = error as Error & { code?: string; errno?: number | string; cause?: unknown };
if (errorWithCode.code) details.push(`code=${errorWithCode.code}`);
if (typeof errorWithCode.errno !== "undefined") details.push(`errno=${String(errorWithCode.errno)}`);
if (typeof error.cause !== "undefined") {
details.push(`cause=${formatErrorDetails(error.cause)}`);
}
if (error.stack) {
details.push(`stack=${error.stack}`);
}
return details.join("; ");
}
return String(error);
}
async function startCallbackServer(expectedState: string): Promise<CallbackServerInfo> {
const { createServer } = await getNodeApis();
return new Promise((resolve, reject) => {
let result: { code: string; state: string } | null = null;
let cancelled = false;
const server = createServer((req, res) => {
try {
const url = new URL(req.url || "", "http://localhost");
if (url.pathname !== CALLBACK_PATH) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not found");
return;
}
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
if (error) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p></body></html>`);
return;
}
if (!code || !state) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
);
return;
}
if (state !== expectedState) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(`<html><body><h1>Authentication Failed</h1><p>State mismatch.</p></body></html>`);
return;
}
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(SUCCESS_HTML);
result = { code, state };
} catch {
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Internal error");
}
});
server.on("error", (err) => {
reject(err);
});
server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
resolve({
server,
redirectUri: REDIRECT_URI,
cancelWait: () => {
cancelled = true;
},
waitForCode: async () => {
const sleep = () => new Promise((r) => setTimeout(r, 100));
while (!result && !cancelled) {
await sleep();
}
return result;
},
});
});
});
}
async function postJsonWithCurl(url: string, body: Record<string, string | number>): Promise<string> {
const { execFile } = await getNodeApis();
const payload = JSON.stringify(body);
const marker = "__PI_CURL_HTTP_STATUS__:";
const result = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFile(
"curl",
[
"-sS",
"--connect-timeout",
"15",
"--max-time",
"30",
"-X",
"POST",
url,
"-H",
"Content-Type: application/json",
"-H",
"Accept: application/json",
"--data-binary",
payload,
"-w",
`\n${marker}%{http_code}`,
],
(error, stdout, stderr) => {
if (error) {
reject(
new Error(`curl request failed. url=${url}; details=${formatErrorDetails(error)}; stderr=${stderr}`),
);
return;
}
resolve({ stdout, stderr });
},
);
});
const markerIndex = result.stdout.lastIndexOf(`\n${marker}`);
if (markerIndex === -1) {
throw new Error(
`curl response missing status marker. url=${url}; stdout=${result.stdout}; stderr=${result.stderr}`,
);
}
const responseBody = result.stdout.slice(0, markerIndex);
const statusText = result.stdout.slice(markerIndex + `\n${marker}`.length).trim();
const status = Number.parseInt(statusText, 10);
if (!Number.isFinite(status)) {
throw new Error(`curl returned invalid status. url=${url}; status=${statusText}; body=${responseBody}`);
}
if (status < 200 || status >= 300) {
throw new Error(`HTTP request failed. status=${status}; url=${url}; body=${responseBody}`);
}
return responseBody;
}
async function exchangeAuthorizationCode(
code: string,
state: string,
verifier: string,
redirectUri: string,
): Promise<OAuthCredentials> {
let responseBody: string;
try {
responseBody = await postJsonWithCurl(TOKEN_URL, {
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
state,
redirect_uri: redirectUri,
code_verifier: verifier,
});
} catch (error) {
throw new Error(
`Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`,
);
}
let tokenData: { access_token: string; refresh_token: string; expires_in: number };
try {
tokenData = JSON.parse(responseBody) as { access_token: string; refresh_token: string; expires_in: number };
} catch (error) {
throw new Error(
`Token exchange returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`,
);
}
// Save credentials
return {
refresh: tokenData.refresh_token,
access: tokenData.access_token,
expires: expiresAt,
expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
};
}
/**
* Login with Anthropic OAuth (authorization code + PKCE)
*/
export async function loginAnthropic(options: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
onManualCodeInput?: () => Promise<string>;
}): Promise<OAuthCredentials> {
const { verifier, challenge } = await generatePKCE();
const server = await startCallbackServer(verifier);
let code: string | undefined;
let state: string | undefined;
let redirectUriForExchange = REDIRECT_URI;
try {
const authParams = new URLSearchParams({
code: "true",
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES,
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
});
options.onAuth({
url: `${AUTHORIZE_URL}?${authParams.toString()}`,
instructions:
"Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.",
});
if (options.onManualCodeInput) {
let manualInput: string | undefined;
let manualError: Error | undefined;
const manualPromise = options
.onManualCodeInput()
.then((input) => {
manualInput = input;
server.cancelWait();
})
.catch((err) => {
manualError = err instanceof Error ? err : new Error(String(err));
server.cancelWait();
});
const result = await server.waitForCode();
if (manualError) {
throw manualError;
}
if (result?.code) {
code = result.code;
state = result.state;
redirectUriForExchange = REDIRECT_URI;
} else if (manualInput) {
const parsed = parseAuthorizationInput(manualInput);
if (parsed.state && parsed.state !== verifier) {
throw new Error("OAuth state mismatch");
}
code = parsed.code;
state = parsed.state ?? verifier;
redirectUriForExchange = MANUAL_REDIRECT_URI;
}
if (!code) {
await manualPromise;
if (manualError) {
throw manualError;
}
if (manualInput) {
const parsed = parseAuthorizationInput(manualInput);
if (parsed.state && parsed.state !== verifier) {
throw new Error("OAuth state mismatch");
}
code = parsed.code;
state = parsed.state ?? verifier;
redirectUriForExchange = MANUAL_REDIRECT_URI;
}
}
} else {
const result = await server.waitForCode();
if (result?.code) {
code = result.code;
state = result.state;
redirectUriForExchange = REDIRECT_URI;
}
}
if (!code) {
const input = await options.onPrompt({
message: "Paste the authorization code or full redirect URL:",
placeholder: MANUAL_REDIRECT_URI,
});
const parsed = parseAuthorizationInput(input);
if (parsed.state && parsed.state !== verifier) {
throw new Error("OAuth state mismatch");
}
code = parsed.code;
state = parsed.state ?? verifier;
redirectUriForExchange = MANUAL_REDIRECT_URI;
}
if (!code) {
throw new Error("Missing authorization code");
}
if (!state) {
throw new Error("Missing OAuth state");
}
options.onProgress?.("Exchanging authorization code for tokens...");
return exchangeAuthorizationCode(code, state, verifier, redirectUriForExchange);
} finally {
server.server.close();
}
}
/**
* Refresh Anthropic OAuth token
*/
export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
let responseBody: string;
try {
responseBody = await postJsonWithCurl(TOKEN_URL, {
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Anthropic token refresh failed: ${error}`);
scope: SCOPES,
});
} catch (error) {
throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
let data: { access_token: string; refresh_token: string; expires_in: number; scope?: string };
try {
data = JSON.parse(responseBody) as {
access_token: string;
refresh_token: string;
expires_in: number;
scope?: string;
};
} catch (error) {
throw new Error(
`Anthropic token refresh returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`,
);
}
return {
refresh: data.refresh_token,
@@ -120,12 +440,15 @@ export async function refreshAnthropicToken(refreshToken: string): Promise<OAuth
export const anthropicOAuthProvider: OAuthProviderInterface = {
id: "anthropic",
name: "Anthropic (Claude Pro/Max)",
usesCallbackServer: true,
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
return loginAnthropic(
(url) => callbacks.onAuth({ url }),
() => callbacks.onPrompt({ message: "Paste the authorization code:" }),
);
return loginAnthropic({
onAuth: callbacks.onAuth,
onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress,
onManualCodeInput: callbacks.onManualCodeInput,
});
},
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {