diff --git a/extensions/google/oauth.flow.ts b/extensions/google/oauth.flow.ts index e5e3f91ad2e..607882267e8 100644 --- a/extensions/google/oauth.flow.ts +++ b/extensions/google/oauth.flow.ts @@ -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( - "
" + - "You can close this window and return to OpenClaw.
", - ); - - 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, }); } diff --git a/extensions/msteams/src/oauth.flow.ts b/extensions/msteams/src/oauth.flow.ts index 1e2a4d9e341..a5d4ad49fcf 100644 --- a/extensions/msteams/src/oauth.flow.ts +++ b/extensions/msteams/src/oauth.flow.ts @@ -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( - "" + - "You can close this window and return to OpenClaw.
", - ); - - 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, }); } diff --git a/src/plugin-sdk/oauth-utils.ts b/src/plugin-sdk/oauth-utils.ts index e96a1856946..6642cd5f502 100644 --- a/src/plugin-sdk/oauth-utils.ts +++ b/src/plugin-sdk/oauth-utils.ts @@ -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 }; +} diff --git a/src/plugin-sdk/provider-auth-runtime.test.ts b/src/plugin-sdk/provider-auth-runtime.test.ts index fc840212786..0f89cb4df18 100644 --- a/src/plugin-sdk/provider-auth-runtime.test.ts +++ b/src/plugin-sdk/provider-auth-runtime.test.ts @@ -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"); + }); }); diff --git a/src/plugin-sdk/provider-auth-runtime.ts b/src/plugin-sdk/provider-auth-runtime.ts index f5717564207..1b24f878fc3 100644 --- a/src/plugin-sdk/provider-auth-runtime.ts +++ b/src/plugin-sdk/provider-auth-runtime.ts @@ -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; +}): PromiseYou can close this window and return to OpenClaw.
", + ); + + 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, "'"); +} + export function isCodexBridgeableOAuthCredential(value: unknown): value is OAuthCredential { return Boolean( value && diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index 14fc03f3e3e..35f77b24d1c 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -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, diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index a8d4dafec53..281eeb904fb 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -977,6 +977,7 @@ describe("plugin-sdk subpath exports", () => { expectSourceOmitsImportPattern("provider-setup", "./sglang.js"); expectSourceMentions("provider-auth", [ "buildOauthProviderAuthResult", + "generateHexPkceVerifierChallenge", "generatePkceVerifierChallenge", "toFormUrlEncoded", ]);