462 lines
13 KiB
TypeScript
462 lines
13 KiB
TypeScript
/**
|
|
* 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, 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://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>`;
|
|
|
|
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;
|
|
}
|
|
|
|
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
|
|
const value = input.trim();
|
|
if (!value) return {};
|
|
|
|
try {
|
|
const url = new URL(value);
|
|
return {
|
|
code: url.searchParams.get("code") ?? undefined,
|
|
state: url.searchParams.get("state") ?? undefined,
|
|
};
|
|
} catch {
|
|
// not a URL
|
|
}
|
|
|
|
if (value.includes("#")) {
|
|
const [code, state] = value.split("#", 2);
|
|
return { code, state };
|
|
}
|
|
|
|
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)}`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
refresh: tokenData.refresh_token,
|
|
access: tokenData.access_token,
|
|
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> {
|
|
let responseBody: string;
|
|
try {
|
|
responseBody = await postJsonWithCurl(TOKEN_URL, {
|
|
grant_type: "refresh_token",
|
|
client_id: CLIENT_ID,
|
|
refresh_token: refreshToken,
|
|
scope: SCOPES,
|
|
});
|
|
} catch (error) {
|
|
throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`);
|
|
}
|
|
|
|
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,
|
|
access: data.access_token,
|
|
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
|
|
};
|
|
}
|
|
|
|
export const anthropicOAuthProvider: OAuthProviderInterface = {
|
|
id: "anthropic",
|
|
name: "Anthropic (Claude Pro/Max)",
|
|
usesCallbackServer: true,
|
|
|
|
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
|
return loginAnthropic({
|
|
onAuth: callbacks.onAuth,
|
|
onPrompt: callbacks.onPrompt,
|
|
onProgress: callbacks.onProgress,
|
|
onManualCodeInput: callbacks.onManualCodeInput,
|
|
});
|
|
},
|
|
|
|
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
|
return refreshAnthropicToken(credentials.refresh);
|
|
},
|
|
|
|
getApiKey(credentials: OAuthCredentials): string {
|
|
return credentials.access;
|
|
},
|
|
};
|