Auth: add Chutes OAuth
This commit is contained in:
committed by
Peter Steinberger
parent
9b44c80b30
commit
4efb5cc18e
97
src/agents/auth-profiles.chutes.test.ts
Normal file
97
src/agents/auth-profiles.chutes.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
CHUTES_TOKEN_ENDPOINT,
|
||||
type ChutesStoredOAuth,
|
||||
} from "./chutes-oauth.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
resolveApiKeyForProfile,
|
||||
type AuthProfileStore,
|
||||
} from "./auth-profiles.js";
|
||||
|
||||
describe("auth-profiles (chutes)", () => {
|
||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const previousChutesClientId = process.env.CHUTES_CLIENT_ID;
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
||||
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
if (previousChutesClientId === undefined) delete process.env.CHUTES_CLIENT_ID;
|
||||
else process.env.CHUTES_CLIENT_ID = previousChutesClientId;
|
||||
});
|
||||
|
||||
it("refreshes expired Chutes OAuth credentials", async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
|
||||
const authProfilePath = path.join(
|
||||
tempDir,
|
||||
"agents",
|
||||
"main",
|
||||
"agent",
|
||||
"auth-profiles.json",
|
||||
);
|
||||
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
|
||||
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"chutes:default": {
|
||||
type: "oauth",
|
||||
provider: "chutes",
|
||||
access: "at_old",
|
||||
refresh: "rt_old",
|
||||
expires: Date.now() - 60_000,
|
||||
clientId: "cid_test",
|
||||
} as unknown as ChutesStoredOAuth,
|
||||
},
|
||||
};
|
||||
await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`);
|
||||
|
||||
const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "at_new",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
const loaded = ensureAuthProfileStore();
|
||||
const resolved = await resolveApiKeyForProfile({
|
||||
store: loaded,
|
||||
profileId: "chutes:default",
|
||||
});
|
||||
|
||||
expect(resolved?.apiKey).toBe("at_new");
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(authProfilePath, "utf8")) as {
|
||||
profiles?: Record<string, { access?: string }>;
|
||||
};
|
||||
expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new");
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
readCodexCliCredentialsCached,
|
||||
writeClaudeCliCredentials,
|
||||
} from "./cli-credentials.js";
|
||||
import { refreshChutesTokens, type ChutesStoredOAuth } from "./chutes-oauth.js";
|
||||
import { normalizeProviderId } from "./model-selection.js";
|
||||
|
||||
const AUTH_STORE_VERSION = 1;
|
||||
@@ -212,7 +213,16 @@ async function refreshOAuthTokenWithLock(params: {
|
||||
const oauthCreds: Record<string, OAuthCredentials> = {
|
||||
[cred.provider]: cred,
|
||||
};
|
||||
const result = await getOAuthApiKey(cred.provider, oauthCreds);
|
||||
|
||||
const result =
|
||||
String(cred.provider) === "chutes"
|
||||
? await (async () => {
|
||||
const newCredentials = await refreshChutesTokens({
|
||||
credential: cred as unknown as ChutesStoredOAuth,
|
||||
});
|
||||
return { apiKey: newCredentials.access, newCredentials };
|
||||
})()
|
||||
: await getOAuthApiKey(cred.provider, oauthCreds);
|
||||
if (!result) return null;
|
||||
store.profiles[params.profileId] = {
|
||||
...cred,
|
||||
|
||||
99
src/agents/chutes-oauth.test.ts
Normal file
99
src/agents/chutes-oauth.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CHUTES_TOKEN_ENDPOINT,
|
||||
CHUTES_USERINFO_ENDPOINT,
|
||||
exchangeChutesCodeForTokens,
|
||||
refreshChutesTokens,
|
||||
} from "./chutes-oauth.js";
|
||||
|
||||
describe("chutes-oauth", () => {
|
||||
it("exchanges code for tokens and stores username as email", async () => {
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url === CHUTES_TOKEN_ENDPOINT) {
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(String(init?.headers && (init.headers as Record<string, string>)["Content-Type"])).toContain(
|
||||
"application/x-www-form-urlencoded",
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "at_123",
|
||||
refresh_token: "rt_123",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url === CHUTES_USERINFO_ENDPOINT) {
|
||||
expect(
|
||||
String(
|
||||
init?.headers && (init.headers as Record<string, string>).Authorization,
|
||||
),
|
||||
).toBe("Bearer at_123");
|
||||
return new Response(JSON.stringify({ username: "fred", sub: "sub_1" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
};
|
||||
|
||||
const now = 1_000_000;
|
||||
const creds = await exchangeChutesCodeForTokens({
|
||||
app: {
|
||||
clientId: "cid_test",
|
||||
redirectUri: "http://127.0.0.1:1456/oauth-callback",
|
||||
scopes: ["openid"],
|
||||
},
|
||||
code: "code_123",
|
||||
codeVerifier: "verifier_123",
|
||||
fetchFn,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(creds.access).toBe("at_123");
|
||||
expect(creds.refresh).toBe("rt_123");
|
||||
expect(creds.email).toBe("fred");
|
||||
expect((creds as unknown as { accountId?: string }).accountId).toBe("sub_1");
|
||||
expect((creds as unknown as { clientId?: string }).clientId).toBe("cid_test");
|
||||
expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
|
||||
const fetchFn: typeof fetch = async (input, init) => {
|
||||
const url = String(input);
|
||||
if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 });
|
||||
expect(init?.method).toBe("POST");
|
||||
const body = init?.body as URLSearchParams;
|
||||
expect(String(body.get("grant_type"))).toBe("refresh_token");
|
||||
expect(String(body.get("client_id"))).toBe("cid_test");
|
||||
expect(String(body.get("refresh_token"))).toBe("rt_old");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "at_new",
|
||||
expires_in: 1800,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
};
|
||||
|
||||
const now = 2_000_000;
|
||||
const refreshed = await refreshChutesTokens({
|
||||
credential: {
|
||||
access: "at_old",
|
||||
refresh: "rt_old",
|
||||
expires: now - 10_000,
|
||||
email: "fred",
|
||||
clientId: "cid_test",
|
||||
} as unknown as Parameters<typeof refreshChutesTokens>[0]["credential"],
|
||||
fetchFn,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(refreshed.access).toBe("at_new");
|
||||
expect(refreshed.refresh).toBe("rt_old");
|
||||
expect(refreshed.expires).toBe(now + 1800 * 1000 - 5 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
204
src/agents/chutes-oauth.ts
Normal file
204
src/agents/chutes-oauth.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
|
||||
export const CHUTES_OAUTH_ISSUER = "https://api.chutes.ai";
|
||||
export const CHUTES_AUTHORIZE_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/authorize`;
|
||||
export const CHUTES_TOKEN_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/token`;
|
||||
export const CHUTES_USERINFO_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/userinfo`;
|
||||
|
||||
const DEFAULT_EXPIRES_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
export type ChutesPkce = { verifier: string; challenge: string };
|
||||
|
||||
export type ChutesUserInfo = {
|
||||
sub?: string;
|
||||
username?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type ChutesOAuthAppConfig = {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
redirectUri: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
export type ChutesStoredOAuth = OAuthCredentials & {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
};
|
||||
|
||||
export function generateChutesPkce(): ChutesPkce {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
export function parseOAuthCallbackInput(
|
||||
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. Paste the full URL." };
|
||||
}
|
||||
return { code, state };
|
||||
} catch {
|
||||
if (!expectedState) {
|
||||
return { error: "Paste the full redirect URL, not just the code." };
|
||||
}
|
||||
return { code: trimmed, state: expectedState };
|
||||
}
|
||||
}
|
||||
|
||||
function coerceExpiresAt(expiresInSeconds: number, now: number): number {
|
||||
const value =
|
||||
now + Math.max(0, Math.floor(expiresInSeconds)) * 1000 - DEFAULT_EXPIRES_BUFFER_MS;
|
||||
return Math.max(value, now + 30_000);
|
||||
}
|
||||
|
||||
export async function fetchChutesUserInfo(params: {
|
||||
accessToken: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<ChutesUserInfo | null> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const response = await fetchFn(CHUTES_USERINFO_ENDPOINT, {
|
||||
headers: { Authorization: `Bearer ${params.accessToken}` },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as unknown;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const typed = data as ChutesUserInfo;
|
||||
return typed;
|
||||
}
|
||||
|
||||
export async function exchangeChutesCodeForTokens(params: {
|
||||
app: ChutesOAuthAppConfig;
|
||||
code: string;
|
||||
codeVerifier: string;
|
||||
fetchFn?: typeof fetch;
|
||||
now?: number;
|
||||
}): Promise<ChutesStoredOAuth> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const now = params.now ?? Date.now();
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: params.app.clientId,
|
||||
code: params.code,
|
||||
redirect_uri: params.app.redirectUri,
|
||||
code_verifier: params.codeVerifier,
|
||||
});
|
||||
if (params.app.clientSecret) {
|
||||
body.set("client_secret", params.app.clientSecret);
|
||||
}
|
||||
|
||||
const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Chutes token exchange failed: ${text}`);
|
||||
}
|
||||
|
||||
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 = data.expires_in ?? 0;
|
||||
|
||||
if (!access) throw new Error("Chutes token exchange returned no access_token");
|
||||
if (!refresh) {
|
||||
throw new Error("Chutes token exchange returned no refresh_token");
|
||||
}
|
||||
|
||||
const info = await fetchChutesUserInfo({ accessToken: access, fetchFn });
|
||||
|
||||
return {
|
||||
access,
|
||||
refresh,
|
||||
expires: coerceExpiresAt(expiresIn, now),
|
||||
email: info?.username,
|
||||
accountId: info?.sub,
|
||||
clientId: params.app.clientId,
|
||||
clientSecret: params.app.clientSecret,
|
||||
} as unknown as ChutesStoredOAuth;
|
||||
}
|
||||
|
||||
export async function refreshChutesTokens(params: {
|
||||
credential: ChutesStoredOAuth;
|
||||
fetchFn?: typeof fetch;
|
||||
now?: number;
|
||||
}): Promise<ChutesStoredOAuth> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const now = params.now ?? Date.now();
|
||||
|
||||
const refreshToken = params.credential.refresh?.trim();
|
||||
if (!refreshToken) {
|
||||
throw new Error("Chutes OAuth credential is missing refresh token");
|
||||
}
|
||||
|
||||
const clientId =
|
||||
params.credential.clientId?.trim() ?? process.env.CHUTES_CLIENT_ID?.trim();
|
||||
if (!clientId) {
|
||||
throw new Error(
|
||||
"Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).",
|
||||
);
|
||||
}
|
||||
const clientSecret =
|
||||
params.credential.clientSecret?.trim() ??
|
||||
process.env.CHUTES_CLIENT_SECRET?.trim() ??
|
||||
undefined;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: clientId,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
if (clientSecret) body.set("client_secret", clientSecret);
|
||||
|
||||
const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Chutes token refresh failed: ${text}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
const access = data.access_token?.trim();
|
||||
const newRefresh = data.refresh_token?.trim();
|
||||
const expiresIn = data.expires_in ?? 0;
|
||||
|
||||
if (!access) throw new Error("Chutes token refresh returned no access_token");
|
||||
|
||||
return {
|
||||
...params.credential,
|
||||
access,
|
||||
refresh: newRefresh || refreshToken,
|
||||
expires: coerceExpiresAt(expiresIn, now),
|
||||
clientId,
|
||||
clientSecret,
|
||||
} as unknown as ChutesStoredOAuth;
|
||||
}
|
||||
|
||||
@@ -125,6 +125,10 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
if (normalized === "chutes") {
|
||||
return pick("CHUTES_OAUTH_TOKEN") ?? pick("CHUTES_API_KEY");
|
||||
}
|
||||
|
||||
if (normalized === "zai") {
|
||||
return pick("ZAI_API_KEY") ?? pick("Z_AI_API_KEY");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user