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.
153 lines
5.3 KiB
TypeScript
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 };
|
|
}
|