feat(extensions): add ironclaw-auth extension with OAuth device flow

This commit is contained in:
kumarabhirup 2026-03-02 18:36:33 -08:00
parent ba65d0b4e8
commit ebde96c11b
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
4 changed files with 660 additions and 0 deletions

View File

@ -0,0 +1,39 @@
# Ironclaw OAuth (OpenClaw plugin)
OAuth provider plugin for Ironclaw-hosted models.
## Enable
Bundled plugins are disabled by default. Enable this one:
```bash
openclaw plugins enable ironclaw-auth
```
Restart the Gateway after enabling.
## Authenticate
Set at least a client id, then run provider login:
```bash
export IRONCLAW_OAUTH_CLIENT_ID="<your-client-id>"
openclaw models auth login --provider ironclaw --set-default
```
## Optional env vars
- `IRONCLAW_OAUTH_CLIENT_SECRET`
- `IRONCLAW_OAUTH_AUTH_URL` (default: `https://auth.ironclaw.ai/oauth/authorize`)
- `IRONCLAW_OAUTH_TOKEN_URL` (default: `https://auth.ironclaw.ai/oauth/token`)
- `IRONCLAW_OAUTH_REDIRECT_URI` (default: `http://127.0.0.1:47089/oauth/callback`)
- `IRONCLAW_OAUTH_SCOPES` (space/comma separated)
- `IRONCLAW_OAUTH_USERINFO_URL` (optional for email display)
- `IRONCLAW_PROVIDER_BASE_URL` (default: `https://api.ironclaw.ai/v1`)
- `IRONCLAW_PROVIDER_MODEL_IDS` (space/comma separated, default: `chat`)
- `IRONCLAW_PROVIDER_DEFAULT_MODEL` (default: first model id)
## Notes
- This plugin configures `models.providers.ironclaw` as `openai-completions`.
- OAuth tokens are stored in auth profiles and the provider is patched into config automatically.

View File

@ -0,0 +1,258 @@
import {
emptyPluginConfigSchema,
type OpenClawPluginApi,
type ProviderAuthContext,
type ProviderAuthResult,
} from "openclaw/plugin-sdk";
import { loginIronclawOAuth, type IronclawOAuthConfig } from "./oauth.js";
const PLUGIN_ID = "ironclaw-auth";
const PROVIDER_ID = "ironclaw";
const PROVIDER_LABEL = "Ironclaw";
const OAUTH_PLACEHOLDER = "ironclaw-oauth";
const DEFAULT_AUTH_URL = "https://auth.ironclaw.ai/oauth/authorize";
const DEFAULT_TOKEN_URL = "https://auth.ironclaw.ai/oauth/token";
const DEFAULT_REDIRECT_URI = "http://127.0.0.1:47089/oauth/callback";
const DEFAULT_SCOPES = ["openid", "profile", "email", "offline_access"];
const DEFAULT_BASE_URL = "https://api.ironclaw.ai/v1";
const DEFAULT_MODEL_ID = "chat";
const DEFAULT_CONTEXT_WINDOW = 128000;
const DEFAULT_MAX_TOKENS = 8192;
const CLIENT_ID_KEYS = ["IRONCLAW_OAUTH_CLIENT_ID", "OPENCLAW_IRONCLAW_OAUTH_CLIENT_ID"];
const CLIENT_SECRET_KEYS = [
"IRONCLAW_OAUTH_CLIENT_SECRET",
"OPENCLAW_IRONCLAW_OAUTH_CLIENT_SECRET",
];
const AUTH_URL_KEYS = ["IRONCLAW_OAUTH_AUTH_URL", "OPENCLAW_IRONCLAW_OAUTH_AUTH_URL"];
const TOKEN_URL_KEYS = ["IRONCLAW_OAUTH_TOKEN_URL", "OPENCLAW_IRONCLAW_OAUTH_TOKEN_URL"];
const REDIRECT_URI_KEYS = ["IRONCLAW_OAUTH_REDIRECT_URI", "OPENCLAW_IRONCLAW_OAUTH_REDIRECT_URI"];
const SCOPES_KEYS = ["IRONCLAW_OAUTH_SCOPES", "OPENCLAW_IRONCLAW_OAUTH_SCOPES"];
const USERINFO_URL_KEYS = ["IRONCLAW_OAUTH_USERINFO_URL", "OPENCLAW_IRONCLAW_OAUTH_USERINFO_URL"];
const BASE_URL_KEYS = [
"IRONCLAW_PROVIDER_BASE_URL",
"IRONCLAW_API_BASE_URL",
"OPENCLAW_IRONCLAW_PROVIDER_BASE_URL",
];
const MODEL_IDS_KEYS = [
"IRONCLAW_PROVIDER_MODEL_IDS",
"IRONCLAW_MODEL_IDS",
"OPENCLAW_IRONCLAW_MODEL_IDS",
];
const DEFAULT_MODEL_KEYS = [
"IRONCLAW_PROVIDER_DEFAULT_MODEL",
"IRONCLAW_DEFAULT_MODEL",
"OPENCLAW_IRONCLAW_DEFAULT_MODEL",
];
const ENV_VARS = [
...CLIENT_ID_KEYS,
...CLIENT_SECRET_KEYS,
...AUTH_URL_KEYS,
...TOKEN_URL_KEYS,
...REDIRECT_URI_KEYS,
...SCOPES_KEYS,
...USERINFO_URL_KEYS,
...BASE_URL_KEYS,
...MODEL_IDS_KEYS,
...DEFAULT_MODEL_KEYS,
];
function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) {
return value;
}
}
return undefined;
}
function stripProviderPrefix(model: string): string {
const normalized = model.trim();
if (normalized.startsWith(`${PROVIDER_ID}/`)) {
return normalized.slice(PROVIDER_ID.length + 1);
}
return normalized;
}
function parseList(value: string | undefined): string[] {
if (!value) {
return [];
}
const items = value
.split(/[\s,]+/)
.map((item) => stripProviderPrefix(item))
.map((item) => item.trim())
.filter(Boolean);
return Array.from(new Set(items));
}
function normalizeBaseUrl(value: string | undefined): string {
const raw = value?.trim() || DEFAULT_BASE_URL;
const withProtocol =
raw.startsWith("http://") || raw.startsWith("https://") ? raw : `https://${raw}`;
const withoutTrailingSlash = withProtocol.replace(/\/+$/, "");
return withoutTrailingSlash.endsWith("/v1") ? withoutTrailingSlash : `${withoutTrailingSlash}/v1`;
}
function resolveDefaultModelId(modelIds: string[]): string {
const configured = resolveEnv(DEFAULT_MODEL_KEYS);
const fallback = modelIds[0] ?? DEFAULT_MODEL_ID;
if (!configured) {
return fallback;
}
const normalized = stripProviderPrefix(configured);
return normalized.length > 0 ? normalized : fallback;
}
function buildModelDefinition(modelId: string) {
return {
id: modelId,
name: modelId,
reasoning: false,
input: ["text", "image"] as Array<"text" | "image">,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
};
}
function resolveOAuthConfig(): IronclawOAuthConfig {
const clientId = resolveEnv(CLIENT_ID_KEYS);
if (!clientId) {
throw new Error(
["Ironclaw OAuth client id is required.", `Set one of: ${CLIENT_ID_KEYS.join(", ")}`].join(
"\n",
),
);
}
const scopes = parseList(resolveEnv(SCOPES_KEYS));
return {
clientId,
clientSecret: resolveEnv(CLIENT_SECRET_KEYS),
authUrl: resolveEnv(AUTH_URL_KEYS) || DEFAULT_AUTH_URL,
tokenUrl: resolveEnv(TOKEN_URL_KEYS) || DEFAULT_TOKEN_URL,
redirectUri: resolveEnv(REDIRECT_URI_KEYS) || DEFAULT_REDIRECT_URI,
scopes: scopes.length > 0 ? scopes : DEFAULT_SCOPES,
userInfoUrl: resolveEnv(USERINFO_URL_KEYS),
};
}
function buildAuthResult(params: {
access: string;
refresh: string;
expires: number;
email?: string;
}): ProviderAuthResult {
const providerBaseUrl = normalizeBaseUrl(resolveEnv(BASE_URL_KEYS));
const modelIds = parseList(resolveEnv(MODEL_IDS_KEYS));
const normalizedModelIds = modelIds.length > 0 ? modelIds : [DEFAULT_MODEL_ID];
const defaultModelId = resolveDefaultModelId(normalizedModelIds);
const finalModelIds = normalizedModelIds.includes(defaultModelId)
? normalizedModelIds
: [defaultModelId, ...normalizedModelIds];
const defaultModel = `${PROVIDER_ID}/${defaultModelId}`;
const agentModels = Object.fromEntries(
finalModelIds.map((modelId, index) => [
`${PROVIDER_ID}/${modelId}`,
index === 0 ? { alias: "ironclaw" } : {},
]),
);
return {
profiles: [
{
profileId: `${PROVIDER_ID}:default`,
credential: {
type: "oauth",
provider: PROVIDER_ID,
access: params.access,
refresh: params.refresh,
expires: params.expires,
},
},
],
configPatch: {
models: {
providers: {
[PROVIDER_ID]: {
baseUrl: providerBaseUrl,
apiKey: OAUTH_PLACEHOLDER,
api: "openai-completions",
models: finalModelIds.map((modelId) => buildModelDefinition(modelId)),
},
},
},
agents: {
defaults: {
models: agentModels,
},
},
},
defaultModel,
notes: [
`Configured ${PROVIDER_ID} provider at ${providerBaseUrl}.`,
`Default model set to ${defaultModel}.`,
...(params.email ? [`Authenticated as ${params.email}.`] : []),
],
};
}
const ironclawAuthPlugin = {
id: PLUGIN_ID,
name: "Ironclaw OAuth",
description: "OAuth flow for Ironclaw-hosted models",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerProvider({
id: PROVIDER_ID,
label: PROVIDER_LABEL,
docsPath: "/providers/models",
aliases: ["ironclaw-ai"],
envVars: ENV_VARS,
auth: [
{
id: "oauth",
label: "Ironclaw OAuth",
hint: "PKCE + localhost callback",
kind: "oauth",
run: async (ctx: ProviderAuthContext) => {
const progress = ctx.prompter.progress("Starting Ironclaw OAuth...");
try {
const oauthConfig = resolveOAuthConfig();
const result = await loginIronclawOAuth(
{
isRemote: ctx.isRemote,
openUrl: ctx.openUrl,
log: (message) => ctx.runtime.log(message),
note: ctx.prompter.note,
prompt: async (message) => String(await ctx.prompter.text({ message })),
progress,
},
oauthConfig,
);
progress.stop("Ironclaw OAuth complete");
return buildAuthResult(result);
} catch (error) {
progress.stop("Ironclaw OAuth failed");
await ctx.prompter.note(
[
"Set IRONCLAW_OAUTH_CLIENT_ID (and optionally auth/token URLs) before retrying.",
"You can also configure model ids with IRONCLAW_PROVIDER_MODEL_IDS.",
].join("\n"),
"Ironclaw OAuth",
);
throw error;
}
},
},
],
});
},
};
export default ironclawAuthPlugin;

View File

@ -0,0 +1,354 @@
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;
}

View File

@ -0,0 +1,9 @@
{
"id": "ironclaw-auth",
"providers": ["ironclaw"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}