evOo-local/packages/adapters/src/pi-anthropic-oauth.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

153 lines
5.3 KiB
TypeScript

import { createHash, randomBytes } from "node:crypto";
import type { AuthInteraction, OAuthCredential } from "@earendil-works/pi-ai";
import { readBodyCapped, withAbort } from "./web-ssrf.js";
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
const REDIRECT_URI = "http://localhost:53692/callback";
const SCOPES =
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
export const MAX_ANTHROPIC_TOKEN_RESPONSE_BYTES = 64 * 1024;
// Public OAuth client identifier used by Claude Code. This is not a client secret.
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
type ManualAnthropicOAuthOptions = {
fetch?: typeof fetch;
createVerifier?: () => string;
};
export function createManualAnthropicOAuthLogin(options: ManualAnthropicOAuthOptions = {}) {
const request = options.fetch ?? fetch;
const createVerifier = options.createVerifier ?? (() => randomBytes(32).toString("base64url"));
return async (interaction: AuthInteraction): Promise<OAuthCredential> => {
const signal = interaction.signal ?? new AbortController().signal;
const verifier = createVerifier();
const challenge = createHash("sha256").update(verifier).digest("base64url");
const authorizeUrl = new URL(AUTHORIZE_URL);
authorizeUrl.search = 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,
}).toString();
interaction.notify({
type: "auth_url",
url: authorizeUrl.toString(),
instructions: "Complete login in your browser, then paste the final redirect URL or code.",
});
const input = await interaction.prompt({
type: "manual_code",
message: "Paste the authorization code or final redirect URL:",
placeholder: REDIRECT_URI,
signal,
});
const authorization = parseAuthorizationInput(input);
if (!authorization.code) throw new Error("Missing authorization code.");
if (authorization.state && authorization.state !== verifier) {
throw new Error("OAuth state mismatch.");
}
const requestSignal = AbortSignal.any([signal, AbortSignal.timeout(30_000)]);
const response = await request(TOKEN_URL, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code: authorization.code,
state: authorization.state ?? verifier,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
}),
signal: requestSignal,
});
if (!response.ok) {
throw new Error(`Anthropic token exchange failed (${response.status}). Start sign-in again.`);
}
const body = await readTokenResponse(response, requestSignal);
if (!body || typeof body !== "object") {
throw new Error("Anthropic token exchange returned an invalid credential.");
}
const credential = body as {
access_token?: unknown;
refresh_token?: unknown;
expires_in?: unknown;
};
if (
typeof credential.access_token !== "string" ||
typeof credential.refresh_token !== "string" ||
typeof credential.expires_in !== "number" ||
!Number.isFinite(credential.expires_in) ||
credential.expires_in <= 0
) {
throw new Error("Anthropic token exchange returned an invalid credential.");
}
return {
type: "oauth",
access: credential.access_token,
refresh: credential.refresh_token,
expires: Date.now() + credential.expires_in * 1000 - 5 * 60 * 1000,
};
};
}
async function readTokenResponse(response: Response, signal: AbortSignal): Promise<unknown> {
const declared = Number(response.headers.get("content-length") ?? 0);
if (Number.isFinite(declared) && declared > MAX_ANTHROPIC_TOKEN_RESPONSE_BYTES) {
const cancel = response.body?.cancel() ?? Promise.resolve();
await withAbort(
cancel.catch(() => undefined),
signal,
).catch(() => undefined);
throw new Error("Anthropic token exchange response is too large.");
}
try {
const bytes = await readBodyCapped(response, MAX_ANTHROPIC_TOKEN_RESPONSE_BYTES, signal);
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
} catch (error) {
if (error instanceof Error && error.message === "Response is too large") {
throw new Error("Anthropic token exchange response is too large.");
}
throw error;
}
}
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 {
// Continue with the compact forms supported by the provider flow.
}
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 };
}