355 lines
9.7 KiB
TypeScript

import { createHash, randomBytes } from "node:crypto";
import { createServer } from "node:http";
import { isWSL2Sync } from "openclaw/plugin-sdk";
const RESPONSE_PAGE = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Ironclaw OAuth</title>
</head>
<body>
<main>
<h1>Authentication complete</h1>
<p>You can return to the terminal.</p>
</main>
</body>
</html>`;
export type IronclawOAuthConfig = {
clientId: string;
clientSecret?: string;
authUrl: string;
tokenUrl: string;
redirectUri: string;
scopes: string[];
userInfoUrl?: string;
};
export type IronclawOAuthCredentials = {
access: string;
refresh: string;
expires: number;
email?: string;
};
export type IronclawOAuthContext = {
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
log: (message: string) => void;
note: (message: string, title?: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
progress: { update: (message: string) => void; stop: (message?: string) => void };
};
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("hex");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
return isRemote || isWSL2Sync();
}
function normalizeUrl(value: string, fieldName: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`${fieldName} is required`);
}
try {
return new URL(trimmed).toString();
} catch {
throw new Error(`${fieldName} must be a valid URL`);
}
}
function buildAuthUrl(params: {
config: IronclawOAuthConfig;
challenge: string;
state: string;
}): string {
const authUrl = normalizeUrl(params.config.authUrl, "IRONCLAW_OAUTH_AUTH_URL");
const url = new URL(authUrl);
url.searchParams.set("client_id", params.config.clientId);
url.searchParams.set("response_type", "code");
url.searchParams.set("redirect_uri", params.config.redirectUri);
url.searchParams.set("scope", params.config.scopes.join(" "));
url.searchParams.set("code_challenge", params.challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", params.state);
url.searchParams.set("access_type", "offline");
url.searchParams.set("prompt", "consent");
return url.toString();
}
function parseCallbackInput(
input: string,
expectedState: string,
): { code: string; state: string } | { error: string } {
const trimmed = input.trim();
if (!trimmed) {
return { error: "No input provided" };
}
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state") ?? expectedState;
if (!code) {
return { error: "Missing 'code' parameter in URL" };
}
if (!state) {
return { error: "Missing 'state' parameter in URL" };
}
return { code, state };
} catch {
if (!expectedState) {
return { error: "Paste the full redirect URL instead of only the code." };
}
return { code: trimmed, state: expectedState };
}
}
async function startCallbackServer(params: { redirectUri: string; timeoutMs: number }) {
const redirect = new URL(normalizeUrl(params.redirectUri, "IRONCLAW_OAUTH_REDIRECT_URI"));
const port = redirect.port ? Number(redirect.port) : 80;
const host =
redirect.hostname === "localhost" || redirect.hostname === "127.0.0.1"
? redirect.hostname
: "127.0.0.1";
let settled = false;
let resolveCallback: (url: URL) => void;
let rejectCallback: (error: Error) => void;
const callbackPromise = new Promise<URL>((resolve, reject) => {
resolveCallback = (url) => {
if (settled) {
return;
}
settled = true;
resolve(url);
};
rejectCallback = (error) => {
if (settled) {
return;
}
settled = true;
reject(error);
};
});
const timeout = setTimeout(() => {
rejectCallback(new Error("Timed out waiting for OAuth callback"));
}, params.timeoutMs);
timeout.unref?.();
const server = createServer((request, response) => {
if (!request.url) {
response.writeHead(400, { "Content-Type": "text/plain" });
response.end("Missing callback URL");
return;
}
const callbackUrl = new URL(request.url, `${redirect.protocol}//${redirect.host}`);
if (callbackUrl.pathname !== redirect.pathname) {
response.writeHead(404, { "Content-Type": "text/plain" });
response.end("Not found");
return;
}
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(RESPONSE_PAGE);
resolveCallback(callbackUrl);
setImmediate(() => {
server.close();
});
});
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
server.off("error", onError);
reject(error);
};
server.once("error", onError);
server.listen(port, host, () => {
server.off("error", onError);
resolve();
});
});
return {
waitForCallback: () => callbackPromise,
close: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
};
}
async function exchangeCode(params: {
config: IronclawOAuthConfig;
code: string;
verifier: string;
}): Promise<IronclawOAuthCredentials> {
const tokenUrl = normalizeUrl(params.config.tokenUrl, "IRONCLAW_OAUTH_TOKEN_URL");
const body = new URLSearchParams({
grant_type: "authorization_code",
code: params.code,
redirect_uri: params.config.redirectUri,
code_verifier: params.verifier,
client_id: params.config.clientId,
});
if (params.config.clientSecret?.trim()) {
body.set("client_secret", params.config.clientSecret.trim());
}
const response = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Token exchange failed: ${text || response.statusText}`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
const access = data.access_token?.trim();
const refresh = data.refresh_token?.trim();
const expiresIn = Math.max(60, Number(data.expires_in) || 3600);
if (!access) {
throw new Error("Token exchange returned no access_token");
}
if (!refresh) {
throw new Error("Token exchange returned no refresh_token");
}
return {
access,
refresh,
expires: Date.now() + expiresIn * 1000 - 5 * 60 * 1000,
};
}
async function fetchUserEmail(
config: IronclawOAuthConfig,
accessToken: string,
): Promise<string | undefined> {
if (!config.userInfoUrl?.trim()) {
return undefined;
}
let url: string;
try {
url = normalizeUrl(config.userInfoUrl, "IRONCLAW_OAUTH_USERINFO_URL");
} catch {
return undefined;
}
try {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
return undefined;
}
const payload = (await response.json()) as {
email?: string;
user?: { email?: string };
};
return payload.email ?? payload.user?.email;
} catch {
return undefined;
}
}
export async function loginIronclawOAuth(
ctx: IronclawOAuthContext,
config: IronclawOAuthConfig,
): Promise<IronclawOAuthCredentials> {
const { verifier, challenge } = generatePkce();
const state = randomBytes(16).toString("hex");
const authUrl = buildAuthUrl({ config, challenge, state });
const needsManual = shouldUseManualOAuthFlow(ctx.isRemote);
let callbackServer: Awaited<ReturnType<typeof startCallbackServer>> | null = null;
if (!needsManual) {
try {
callbackServer = await startCallbackServer({
redirectUri: config.redirectUri,
timeoutMs: 5 * 60 * 1000,
});
} catch {
callbackServer = null;
}
}
if (!callbackServer) {
await ctx.note(
[
"Open the URL in your local browser and complete sign-in.",
"After redirect, paste the full callback URL here.",
"",
`Auth URL: ${authUrl}`,
`Redirect URI: ${config.redirectUri}`,
].join("\n"),
"Ironclaw OAuth",
);
ctx.log("");
ctx.log("Copy this URL:");
ctx.log(authUrl);
ctx.log("");
} else {
ctx.progress.update("Opening Ironclaw sign-in...");
try {
await ctx.openUrl(authUrl);
} catch {
ctx.log("");
ctx.log("Open this URL in your browser:");
ctx.log(authUrl);
ctx.log("");
}
}
let code = "";
let returnedState = "";
try {
if (callbackServer) {
ctx.progress.update("Waiting for OAuth callback...");
const callback = await callbackServer.waitForCallback();
code = callback.searchParams.get("code") ?? "";
returnedState = callback.searchParams.get("state") ?? "";
} else {
ctx.progress.update("Waiting for redirect URL...");
const input = await ctx.prompt("Paste the redirect URL: ");
const parsed = parseCallbackInput(input, state);
if ("error" in parsed) {
throw new Error(parsed.error);
}
code = parsed.code;
returnedState = parsed.state;
}
} finally {
await callbackServer?.close().catch(() => undefined);
}
if (!code) {
throw new Error("Missing OAuth code");
}
if (returnedState !== state) {
throw new Error("OAuth state mismatch. Please try again.");
}
ctx.progress.update("Exchanging authorization code...");
const tokens = await exchangeCode({ config, code, verifier });
const email = await fetchUserEmail(config, tokens.access);
return email ? { ...tokens, email } : tokens;
}