Skip to content

Commit 92e765c

Browse files
committed
refactor(google): split oauth flow modules
1 parent 7c0cac2 commit 92e765c

7 files changed

Lines changed: 688 additions & 658 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
2+
import { delimiter, dirname, join } from "node:path";
3+
import { CLIENT_ID_KEYS, CLIENT_SECRET_KEYS } from "./oauth.shared.js";
4+
5+
function resolveEnv(keys: string[]): string | undefined {
6+
for (const key of keys) {
7+
const value = process.env[key]?.trim();
8+
if (value) {
9+
return value;
10+
}
11+
}
12+
return undefined;
13+
}
14+
15+
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
16+
17+
export function clearCredentialsCache(): void {
18+
cachedGeminiCliCredentials = null;
19+
}
20+
21+
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
22+
if (cachedGeminiCliCredentials) {
23+
return cachedGeminiCliCredentials;
24+
}
25+
26+
try {
27+
const geminiPath = findInPath("gemini");
28+
if (!geminiPath) {
29+
return null;
30+
}
31+
32+
const resolvedPath = realpathSync(geminiPath);
33+
const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath);
34+
35+
let content: string | null = null;
36+
for (const geminiCliDir of geminiCliDirs) {
37+
const searchPaths = [
38+
join(
39+
geminiCliDir,
40+
"node_modules",
41+
"@google",
42+
"gemini-cli-core",
43+
"dist",
44+
"src",
45+
"code_assist",
46+
"oauth2.js",
47+
),
48+
join(
49+
geminiCliDir,
50+
"node_modules",
51+
"@google",
52+
"gemini-cli-core",
53+
"dist",
54+
"code_assist",
55+
"oauth2.js",
56+
),
57+
];
58+
59+
for (const path of searchPaths) {
60+
if (existsSync(path)) {
61+
content = readFileSync(path, "utf8");
62+
break;
63+
}
64+
}
65+
if (content) {
66+
break;
67+
}
68+
const found = findFile(geminiCliDir, "oauth2.js", 10);
69+
if (found) {
70+
content = readFileSync(found, "utf8");
71+
break;
72+
}
73+
}
74+
if (!content) {
75+
return null;
76+
}
77+
78+
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
79+
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
80+
if (idMatch && secretMatch) {
81+
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] };
82+
return cachedGeminiCliCredentials;
83+
}
84+
} catch {
85+
// Gemini CLI not installed or extraction failed
86+
}
87+
return null;
88+
}
89+
90+
function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[] {
91+
const binDir = dirname(geminiPath);
92+
const candidates = [
93+
dirname(dirname(resolvedPath)),
94+
join(dirname(resolvedPath), "node_modules", "@google", "gemini-cli"),
95+
join(binDir, "node_modules", "@google", "gemini-cli"),
96+
join(dirname(binDir), "node_modules", "@google", "gemini-cli"),
97+
join(dirname(binDir), "lib", "node_modules", "@google", "gemini-cli"),
98+
];
99+
100+
const deduped: string[] = [];
101+
const seen = new Set<string>();
102+
for (const candidate of candidates) {
103+
const key =
104+
process.platform === "win32" ? candidate.replace(/\\/g, "/").toLowerCase() : candidate;
105+
if (seen.has(key)) {
106+
continue;
107+
}
108+
seen.add(key);
109+
deduped.push(candidate);
110+
}
111+
return deduped;
112+
}
113+
114+
function findInPath(name: string): string | null {
115+
const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""];
116+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
117+
for (const ext of exts) {
118+
const path = join(dir, name + ext);
119+
if (existsSync(path)) {
120+
return path;
121+
}
122+
}
123+
}
124+
return null;
125+
}
126+
127+
function findFile(dir: string, name: string, depth: number): string | null {
128+
if (depth <= 0) {
129+
return null;
130+
}
131+
try {
132+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
133+
const path = join(dir, entry.name);
134+
if (entry.isFile() && entry.name === name) {
135+
return path;
136+
}
137+
if (entry.isDirectory() && !entry.name.startsWith(".")) {
138+
const found = findFile(path, name, depth - 1);
139+
if (found) {
140+
return found;
141+
}
142+
}
143+
}
144+
} catch {}
145+
return null;
146+
}
147+
148+
export function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
149+
const envClientId = resolveEnv(CLIENT_ID_KEYS);
150+
const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS);
151+
if (envClientId) {
152+
return { clientId: envClientId, clientSecret: envClientSecret };
153+
}
154+
155+
const extracted = extractGeminiCliCredentials();
156+
if (extracted) {
157+
return extracted;
158+
}
159+
160+
throw new Error(
161+
"Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.",
162+
);
163+
}

extensions/google/oauth.flow.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { createHash, randomBytes } from "node:crypto";
2+
import { createServer } from "node:http";
3+
import { isWSL2Sync } from "../../src/infra/wsl.js";
4+
import { resolveOAuthClientConfig } from "./oauth.credentials.js";
5+
import { AUTH_URL, REDIRECT_URI, SCOPES } from "./oauth.shared.js";
6+
7+
export function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
8+
return isRemote || isWSL2Sync();
9+
}
10+
11+
export function generatePkce(): { verifier: string; challenge: string } {
12+
const verifier = randomBytes(32).toString("hex");
13+
const challenge = createHash("sha256").update(verifier).digest("base64url");
14+
return { verifier, challenge };
15+
}
16+
17+
export function buildAuthUrl(challenge: string, verifier: string): string {
18+
const { clientId } = resolveOAuthClientConfig();
19+
const params = new URLSearchParams({
20+
client_id: clientId,
21+
response_type: "code",
22+
redirect_uri: REDIRECT_URI,
23+
scope: SCOPES.join(" "),
24+
code_challenge: challenge,
25+
code_challenge_method: "S256",
26+
state: verifier,
27+
access_type: "offline",
28+
prompt: "consent",
29+
});
30+
return `${AUTH_URL}?${params.toString()}`;
31+
}
32+
33+
export function parseCallbackInput(
34+
input: string,
35+
expectedState: string,
36+
): { code: string; state: string } | { error: string } {
37+
const trimmed = input.trim();
38+
if (!trimmed) {
39+
return { error: "No input provided" };
40+
}
41+
42+
try {
43+
const url = new URL(trimmed);
44+
const code = url.searchParams.get("code");
45+
const state = url.searchParams.get("state") ?? expectedState;
46+
if (!code) {
47+
return { error: "Missing 'code' parameter in URL" };
48+
}
49+
if (!state) {
50+
return { error: "Missing 'state' parameter. Paste the full URL." };
51+
}
52+
return { code, state };
53+
} catch {
54+
if (!expectedState) {
55+
return { error: "Paste the full redirect URL, not just the code." };
56+
}
57+
return { code: trimmed, state: expectedState };
58+
}
59+
}
60+
61+
export async function waitForLocalCallback(params: {
62+
expectedState: string;
63+
timeoutMs: number;
64+
onProgress?: (message: string) => void;
65+
}): Promise<{ code: string; state: string }> {
66+
const port = 8085;
67+
const hostname = "localhost";
68+
const expectedPath = "/oauth2callback";
69+
70+
return new Promise<{ code: string; state: string }>((resolve, reject) => {
71+
let timeout: NodeJS.Timeout | null = null;
72+
const server = createServer((req, res) => {
73+
try {
74+
const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`);
75+
if (requestUrl.pathname !== expectedPath) {
76+
res.statusCode = 404;
77+
res.setHeader("Content-Type", "text/plain");
78+
res.end("Not found");
79+
return;
80+
}
81+
82+
const error = requestUrl.searchParams.get("error");
83+
const code = requestUrl.searchParams.get("code")?.trim();
84+
const state = requestUrl.searchParams.get("state")?.trim();
85+
86+
if (error) {
87+
res.statusCode = 400;
88+
res.setHeader("Content-Type", "text/plain");
89+
res.end(`Authentication failed: ${error}`);
90+
finish(new Error(`OAuth error: ${error}`));
91+
return;
92+
}
93+
94+
if (!code || !state) {
95+
res.statusCode = 400;
96+
res.setHeader("Content-Type", "text/plain");
97+
res.end("Missing code or state");
98+
finish(new Error("Missing OAuth code or state"));
99+
return;
100+
}
101+
102+
if (state !== params.expectedState) {
103+
res.statusCode = 400;
104+
res.setHeader("Content-Type", "text/plain");
105+
res.end("Invalid state");
106+
finish(new Error("OAuth state mismatch"));
107+
return;
108+
}
109+
110+
res.statusCode = 200;
111+
res.setHeader("Content-Type", "text/html; charset=utf-8");
112+
res.end(
113+
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
114+
"<body><h2>Gemini CLI OAuth complete</h2>" +
115+
"<p>You can close this window and return to OpenClaw.</p></body></html>",
116+
);
117+
118+
finish(undefined, { code, state });
119+
} catch (err) {
120+
finish(err instanceof Error ? err : new Error("OAuth callback failed"));
121+
}
122+
});
123+
124+
const finish = (err?: Error, result?: { code: string; state: string }) => {
125+
if (timeout) {
126+
clearTimeout(timeout);
127+
}
128+
try {
129+
server.close();
130+
} catch {
131+
// ignore close errors
132+
}
133+
if (err) {
134+
reject(err);
135+
} else if (result) {
136+
resolve(result);
137+
}
138+
};
139+
140+
server.once("error", (err) => {
141+
finish(err instanceof Error ? err : new Error("OAuth callback server error"));
142+
});
143+
144+
server.listen(port, hostname, () => {
145+
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`);
146+
});
147+
148+
timeout = setTimeout(() => {
149+
finish(new Error("OAuth callback timeout"));
150+
}, params.timeoutMs);
151+
});
152+
}

extensions/google/oauth.http.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { fetchWithSsrFGuard } from "../../src/infra/net/fetch-guard.js";
2+
import { DEFAULT_FETCH_TIMEOUT_MS } from "./oauth.shared.js";
3+
4+
export async function fetchWithTimeout(
5+
url: string,
6+
init: RequestInit,
7+
timeoutMs = DEFAULT_FETCH_TIMEOUT_MS,
8+
): Promise<Response> {
9+
const { response, release } = await fetchWithSsrFGuard({
10+
url,
11+
init,
12+
timeoutMs,
13+
});
14+
try {
15+
const body = await response.arrayBuffer();
16+
return new Response(body, {
17+
status: response.status,
18+
statusText: response.statusText,
19+
headers: response.headers,
20+
});
21+
} finally {
22+
await release();
23+
}
24+
}

0 commit comments

Comments
 (0)