feat(extensions): add ironclaw-auth extension with OAuth device flow
This commit is contained in:
parent
ba65d0b4e8
commit
ebde96c11b
39
extensions/ironclaw-auth/README.md
Normal file
39
extensions/ironclaw-auth/README.md
Normal 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.
|
||||
258
extensions/ironclaw-auth/index.ts
Normal file
258
extensions/ironclaw-auth/index.ts
Normal 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;
|
||||
354
extensions/ironclaw-auth/oauth.ts
Normal file
354
extensions/ironclaw-auth/oauth.ts
Normal 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;
|
||||
}
|
||||
9
extensions/ironclaw-auth/openclaw.plugin.json
Normal file
9
extensions/ironclaw-auth/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "ironclaw-auth",
|
||||
"providers": ["ironclaw"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user