mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
refactor: share oauth callback flow
This commit is contained in:
@@ -1,21 +1,21 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { generateHexPkceVerifierChallenge } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
generateOAuthState,
|
||||
parseOAuthCallbackInput,
|
||||
waitForLocalOAuthCallback,
|
||||
} from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import { isWSL2Sync } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveOAuthClientConfig } from "./oauth.credentials.js";
|
||||
import { AUTH_URL, REDIRECT_URI, SCOPES } from "./oauth.shared.js";
|
||||
|
||||
export { generateOAuthState };
|
||||
|
||||
export function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
||||
return isRemote || isWSL2Sync();
|
||||
}
|
||||
|
||||
export function generatePkce(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
export function generateOAuthState(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
return generateHexPkceVerifierChallenge();
|
||||
}
|
||||
|
||||
export function buildAuthUrl(challenge: string, state: string): string {
|
||||
@@ -37,25 +37,10 @@ export function buildAuthUrl(challenge: string, state: string): string {
|
||||
export function parseCallbackInput(
|
||||
input: 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");
|
||||
if (!code) {
|
||||
return { error: "Missing 'code' parameter in URL" };
|
||||
}
|
||||
if (!state) {
|
||||
return { error: "Missing 'state' parameter. Paste the full URL." };
|
||||
}
|
||||
return { code, state };
|
||||
} catch {
|
||||
return { error: "Paste the full redirect URL, not just the code." };
|
||||
}
|
||||
return parseOAuthCallbackInput(input, {
|
||||
missingState: "Missing 'state' parameter. Paste the full URL.",
|
||||
invalidInput: "Paste the full redirect URL, not just the code.",
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForLocalCallback(params: {
|
||||
@@ -63,90 +48,14 @@ export async function waitForLocalCallback(params: {
|
||||
timeoutMs: number;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<{ code: string; state: string }> {
|
||||
const port = 8085;
|
||||
const hostname = "localhost";
|
||||
const expectedPath = "/oauth2callback";
|
||||
|
||||
return new Promise<{ code: string; state: string }>((resolve, reject) => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`);
|
||||
if (requestUrl.pathname !== expectedPath) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const error = requestUrl.searchParams.get("error");
|
||||
const code = requestUrl.searchParams.get("code")?.trim();
|
||||
const state = requestUrl.searchParams.get("state")?.trim();
|
||||
|
||||
if (error) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end(`Authentication failed: ${error}`);
|
||||
finish(new Error(`OAuth error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Missing code or state");
|
||||
finish(new Error("Missing OAuth code or state"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== params.expectedState) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Invalid state");
|
||||
finish(new Error("OAuth state mismatch"));
|
||||
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>Gemini CLI OAuth complete</h2>" +
|
||||
"<p>You can close this window and return to OpenClaw.</p></body></html>",
|
||||
);
|
||||
|
||||
finish(undefined, { code, state });
|
||||
} catch (err) {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback failed"));
|
||||
}
|
||||
});
|
||||
|
||||
const finish = (err?: Error, result?: { code: string; state: string }) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (result) {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
server.once("error", (err) => {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback server error"));
|
||||
});
|
||||
|
||||
server.listen(port, hostname, () => {
|
||||
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`);
|
||||
});
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
finish(new Error("OAuth callback timeout"));
|
||||
}, params.timeoutMs);
|
||||
return await waitForLocalOAuthCallback({
|
||||
expectedState: params.expectedState,
|
||||
timeoutMs: params.timeoutMs,
|
||||
port: 8085,
|
||||
callbackPath: "/oauth2callback",
|
||||
redirectUri: REDIRECT_URI,
|
||||
successTitle: "Gemini CLI OAuth complete",
|
||||
progressMessage: `Waiting for OAuth callback on ${REDIRECT_URI}…`,
|
||||
onProgress: params.onProgress,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import { generateHexPkceVerifierChallenge } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
generateOAuthState,
|
||||
parseOAuthCallbackInput,
|
||||
waitForLocalOAuthCallback,
|
||||
} from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import { isWSL2Sync } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
MSTEAMS_DEFAULT_DELEGATED_SCOPES,
|
||||
@@ -14,15 +18,10 @@ export function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
||||
}
|
||||
|
||||
export function generatePkce(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
return generateHexPkceVerifierChallenge();
|
||||
}
|
||||
|
||||
/** Generate an opaque random state value for OAuth CSRF protection (separate from PKCE verifier). */
|
||||
export function generateOAuthState(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
export { generateOAuthState };
|
||||
|
||||
export function buildMSTeamsAuthUrl(params: {
|
||||
tenantId: string;
|
||||
@@ -53,29 +52,11 @@ export function parseCallbackInput(
|
||||
// The caller compares the parsed `state` against the expected value.
|
||||
_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");
|
||||
if (!code) {
|
||||
return { error: "Missing 'code' parameter in URL" };
|
||||
}
|
||||
if (!state) {
|
||||
return { error: "Missing 'state' parameter in URL. Paste the full redirect URL." };
|
||||
}
|
||||
return { code, state };
|
||||
} catch {
|
||||
// Not a valid URL — reject bare codes to enforce CSRF state verification.
|
||||
return {
|
||||
error:
|
||||
"Paste the full redirect URL (including code and state parameters), not just the authorization code.",
|
||||
};
|
||||
}
|
||||
return parseOAuthCallbackInput(input, {
|
||||
missingState: "Missing 'state' parameter in URL. Paste the full redirect URL.",
|
||||
invalidInput:
|
||||
"Paste the full redirect URL (including code and state parameters), not just the authorization code.",
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForLocalCallback(params: {
|
||||
@@ -83,90 +64,14 @@ export async function waitForLocalCallback(params: {
|
||||
timeoutMs: number;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<{ code: string; state: string }> {
|
||||
const port = MSTEAMS_OAUTH_CALLBACK_PORT;
|
||||
const hostname = "localhost";
|
||||
const expectedPath = MSTEAMS_OAUTH_CALLBACK_PATH;
|
||||
|
||||
return new Promise<{ code: string; state: string }>((resolve, reject) => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`);
|
||||
if (requestUrl.pathname !== expectedPath) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const error = requestUrl.searchParams.get("error");
|
||||
const code = requestUrl.searchParams.get("code")?.trim();
|
||||
const state = requestUrl.searchParams.get("state")?.trim();
|
||||
|
||||
if (error) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end(`Authentication failed: ${error}`);
|
||||
finish(new Error(`OAuth error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Missing code or state");
|
||||
finish(new Error("Missing OAuth code or state"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== params.expectedState) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Invalid state");
|
||||
finish(new Error("OAuth state mismatch"));
|
||||
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>MSTeams Delegated OAuth complete</h2>" +
|
||||
"<p>You can close this window and return to OpenClaw.</p></body></html>",
|
||||
);
|
||||
|
||||
finish(undefined, { code, state });
|
||||
} catch (err) {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback failed"));
|
||||
}
|
||||
});
|
||||
|
||||
const finish = (err?: Error, result?: { code: string; state: string }) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (result) {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
server.once("error", (err) => {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback server error"));
|
||||
});
|
||||
|
||||
server.listen(port, hostname, () => {
|
||||
params.onProgress?.(`Waiting for OAuth callback on ${MSTEAMS_OAUTH_REDIRECT_URI}...`);
|
||||
});
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
finish(new Error("OAuth callback timeout"));
|
||||
}, params.timeoutMs);
|
||||
return await waitForLocalOAuthCallback({
|
||||
expectedState: params.expectedState,
|
||||
timeoutMs: params.timeoutMs,
|
||||
port: MSTEAMS_OAUTH_CALLBACK_PORT,
|
||||
callbackPath: MSTEAMS_OAUTH_CALLBACK_PATH,
|
||||
redirectUri: MSTEAMS_OAUTH_REDIRECT_URI,
|
||||
successTitle: "MSTeams Delegated OAuth complete",
|
||||
progressMessage: `Waiting for OAuth callback on ${MSTEAMS_OAUTH_REDIRECT_URI}...`,
|
||||
onProgress: params.onProgress,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,3 +13,10 @@ export function generatePkceVerifierChallenge(): { verifier: string; challenge:
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/** Generate a PKCE verifier/challenge pair with a 64-character hex verifier. */
|
||||
export function generateHexPkceVerifierChallenge(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
@@ -9,4 +9,10 @@ describe("plugin-sdk provider-auth-runtime", () => {
|
||||
it("exports the Codex auth bridge helper", () => {
|
||||
expect(typeof providerAuthRuntime.prepareCodexAuthBridge).toBe("function");
|
||||
});
|
||||
|
||||
it("exports OAuth callback helpers", () => {
|
||||
expect(typeof providerAuthRuntime.generateOAuthState).toBe("function");
|
||||
expect(typeof providerAuthRuntime.parseOAuthCallbackInput).toBe("function");
|
||||
expect(typeof providerAuthRuntime.waitForLocalOAuthCallback).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { ensureAuthProfileStoreForLocalUpdate } from "../agents/auth-profiles/store.js";
|
||||
@@ -31,6 +32,154 @@ export type PreparedCodexAuthBridge = {
|
||||
clearEnv: string[];
|
||||
};
|
||||
|
||||
export type OAuthCallbackResult = { code: string; state: string };
|
||||
|
||||
export function generateOAuthState(): string {
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
export function parseOAuthCallbackInput(
|
||||
input: string,
|
||||
messages: {
|
||||
missingState?: string;
|
||||
invalidInput?: string;
|
||||
} = {},
|
||||
): OAuthCallbackResult | { 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");
|
||||
if (!code) {
|
||||
return { error: "Missing 'code' parameter in URL" };
|
||||
}
|
||||
if (!state) {
|
||||
return { error: messages.missingState ?? "Missing 'state' parameter in URL" };
|
||||
}
|
||||
return { code, state };
|
||||
} catch {
|
||||
return { error: messages.invalidInput ?? "Paste the full redirect URL, not just the code." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForLocalOAuthCallback(params: {
|
||||
expectedState: string;
|
||||
timeoutMs: number;
|
||||
port: number;
|
||||
callbackPath: string;
|
||||
redirectUri: string;
|
||||
successTitle: string;
|
||||
progressMessage?: string;
|
||||
hostname?: string;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<OAuthCallbackResult> {
|
||||
const hostname = params.hostname ?? "localhost";
|
||||
const escapedSuccessTitle = escapeHtmlText(params.successTitle);
|
||||
|
||||
return new Promise<OAuthCallbackResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${params.port}`);
|
||||
if (requestUrl.pathname !== params.callbackPath) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const error = requestUrl.searchParams.get("error");
|
||||
const code = requestUrl.searchParams.get("code")?.trim();
|
||||
const state = requestUrl.searchParams.get("state")?.trim();
|
||||
|
||||
if (error) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end(`Authentication failed: ${error}`);
|
||||
finish(new Error(`OAuth error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Missing code or state");
|
||||
finish(new Error("Missing OAuth code or state"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== params.expectedState) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Invalid state");
|
||||
finish(new Error("OAuth state mismatch"));
|
||||
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>${escapedSuccessTitle}</h2>` +
|
||||
"<p>You can close this window and return to OpenClaw.</p></body></html>",
|
||||
);
|
||||
|
||||
finish(undefined, { code, state });
|
||||
} catch (err) {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback failed"));
|
||||
}
|
||||
});
|
||||
|
||||
const finish = (err?: Error, result?: OAuthCallbackResult) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (result) {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
server.once("error", (err) => {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback server error"));
|
||||
});
|
||||
|
||||
server.listen(params.port, hostname, () => {
|
||||
params.onProgress?.(
|
||||
params.progressMessage ?? `Waiting for OAuth callback on ${params.redirectUri}...`,
|
||||
);
|
||||
});
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
finish(new Error("OAuth callback timeout"));
|
||||
}, params.timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtmlText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function isCodexBridgeableOAuthCredential(value: unknown): value is OAuthCredential {
|
||||
return Boolean(
|
||||
value &&
|
||||
|
||||
@@ -72,7 +72,11 @@ export {
|
||||
omitEnvKeysCaseInsensitive,
|
||||
} from "../secrets/provider-env-vars.js";
|
||||
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||
export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js";
|
||||
export {
|
||||
generateHexPkceVerifierChallenge,
|
||||
generatePkceVerifierChallenge,
|
||||
toFormUrlEncoded,
|
||||
} from "./oauth-utils.js";
|
||||
export {
|
||||
DEFAULT_OAUTH_REFRESH_MARGIN_MS,
|
||||
hasUsableOAuthCredential,
|
||||
|
||||
@@ -977,6 +977,7 @@ describe("plugin-sdk subpath exports", () => {
|
||||
expectSourceOmitsImportPattern("provider-setup", "./sglang.js");
|
||||
expectSourceMentions("provider-auth", [
|
||||
"buildOauthProviderAuthResult",
|
||||
"generateHexPkceVerifierChallenge",
|
||||
"generatePkceVerifierChallenge",
|
||||
"toFormUrlEncoded",
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user