refactor: share oauth callback flow

This commit is contained in:
Peter Steinberger
2026-04-21 01:03:20 +01:00
parent f85c0b7dc5
commit 3f274006cd
7 changed files with 212 additions and 231 deletions

View File

@@ -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,
});
}

View File

@@ -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,
});
}

View File

@@ -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 };
}

View File

@@ -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");
});
});

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function isCodexBridgeableOAuthCredential(value: unknown): value is OAuthCredential {
return Boolean(
value &&

View File

@@ -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,

View File

@@ -977,6 +977,7 @@ describe("plugin-sdk subpath exports", () => {
expectSourceOmitsImportPattern("provider-setup", "./sglang.js");
expectSourceMentions("provider-auth", [
"buildOauthProviderAuthResult",
"generateHexPkceVerifierChallenge",
"generatePkceVerifierChallenge",
"toFormUrlEncoded",
]);