Auth: add Chutes OAuth
This commit is contained in:
committed by
Peter Steinberger
parent
9b44c80b30
commit
4efb5cc18e
186
src/commands/chutes-oauth.ts
Normal file
186
src/commands/chutes-oauth.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
|
||||
import type { ChutesOAuthAppConfig } from "../agents/chutes-oauth.js";
|
||||
import {
|
||||
exchangeChutesCodeForTokens,
|
||||
generateChutesPkce,
|
||||
parseOAuthCallbackInput,
|
||||
} from "../agents/chutes-oauth.js";
|
||||
|
||||
type OAuthPrompt = {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
function buildAuthorizeUrl(params: {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
scopes: string[];
|
||||
state: string;
|
||||
challenge: string;
|
||||
}): string {
|
||||
const qs = new URLSearchParams({
|
||||
client_id: params.clientId,
|
||||
redirect_uri: params.redirectUri,
|
||||
response_type: "code",
|
||||
scope: params.scopes.join(" "),
|
||||
state: params.state,
|
||||
code_challenge: params.challenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
return `https://api.chutes.ai/idp/authorize?${qs.toString()}`;
|
||||
}
|
||||
|
||||
async function waitForLocalCallback(params: {
|
||||
redirectUri: string;
|
||||
expectedState: string;
|
||||
timeoutMs: number;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<{ code: string; state: string }> {
|
||||
const redirectUrl = new URL(params.redirectUri);
|
||||
if (redirectUrl.protocol !== "http:") {
|
||||
throw new Error(`Chutes OAuth redirect URI must be http:// (got ${params.redirectUri})`);
|
||||
}
|
||||
const hostname = redirectUrl.hostname || "127.0.0.1";
|
||||
const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80;
|
||||
const expectedPath = redirectUrl.pathname || "/";
|
||||
|
||||
let server: ReturnType<typeof createServer> | null = null;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
try {
|
||||
const resultPromise = new Promise<{ code: string; state: string }>(
|
||||
(resolve, reject) => {
|
||||
server = createServer((req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? "/", redirectUrl.origin);
|
||||
if (requestUrl.pathname !== expectedPath) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const code = requestUrl.searchParams.get("code")?.trim();
|
||||
const state = requestUrl.searchParams.get("state")?.trim();
|
||||
|
||||
if (!code) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Missing code");
|
||||
return;
|
||||
}
|
||||
if (!state || state !== params.expectedState) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Invalid state");
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(
|
||||
[
|
||||
"<!doctype html>",
|
||||
"<html><head><meta charset='utf-8' /></head>",
|
||||
"<body><h2>Chutes OAuth complete</h2>",
|
||||
"<p>You can close this window and return to clawdbot.</p></body></html>",
|
||||
].join(""),
|
||||
);
|
||||
resolve({ code, state });
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
server.once("error", reject);
|
||||
server.listen(port, hostname, () => {
|
||||
params.onProgress?.(
|
||||
`Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}…`,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
try {
|
||||
server?.close();
|
||||
} catch {}
|
||||
}, params.timeoutMs);
|
||||
|
||||
return await resultPromise;
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (server) {
|
||||
try {
|
||||
server.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginChutes(params: {
|
||||
app: ChutesOAuthAppConfig;
|
||||
manual?: boolean;
|
||||
timeoutMs?: number;
|
||||
onAuth: (event: { url: string }) => Promise<void>;
|
||||
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
|
||||
onProgress?: (message: string) => void;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<OAuthCredentials> {
|
||||
const { verifier, challenge } = generateChutesPkce();
|
||||
const state = verifier;
|
||||
const timeoutMs = params.timeoutMs ?? 3 * 60 * 1000;
|
||||
|
||||
const url = buildAuthorizeUrl({
|
||||
clientId: params.app.clientId,
|
||||
redirectUri: params.app.redirectUri,
|
||||
scopes: params.app.scopes,
|
||||
state,
|
||||
challenge,
|
||||
});
|
||||
|
||||
let codeAndState: { code: string; state: string };
|
||||
if (params.manual) {
|
||||
await params.onAuth({ url });
|
||||
params.onProgress?.("Waiting for redirect URL…");
|
||||
const input = await params.onPrompt({
|
||||
message: "Paste the redirect URL (or authorization code)",
|
||||
placeholder: `${params.app.redirectUri}?code=...&state=...`,
|
||||
});
|
||||
const parsed = parseOAuthCallbackInput(String(input), state);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
if (parsed.state !== state) throw new Error("Invalid OAuth state");
|
||||
codeAndState = parsed;
|
||||
} else {
|
||||
const callback = waitForLocalCallback({
|
||||
redirectUri: params.app.redirectUri,
|
||||
expectedState: state,
|
||||
timeoutMs,
|
||||
onProgress: params.onProgress,
|
||||
}).catch(async () => {
|
||||
params.onProgress?.("OAuth callback not detected; paste redirect URL…");
|
||||
const input = await params.onPrompt({
|
||||
message: "Paste the redirect URL (or authorization code)",
|
||||
placeholder: `${params.app.redirectUri}?code=...&state=...`,
|
||||
});
|
||||
const parsed = parseOAuthCallbackInput(String(input), state);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
if (parsed.state !== state) throw new Error("Invalid OAuth state");
|
||||
return parsed;
|
||||
});
|
||||
|
||||
await params.onAuth({ url });
|
||||
codeAndState = await callback;
|
||||
}
|
||||
|
||||
params.onProgress?.("Exchanging code for tokens…");
|
||||
return await exchangeChutesCodeForTokens({
|
||||
app: params.app,
|
||||
code: codeAndState.code,
|
||||
codeVerifier: verifier,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user