opencode-gemini-auth 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -9
- package/package.json +1 -1
- package/src/plugin/server.ts +136 -0
- package/src/plugin.ts +64 -5
package/README.md
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# Gemini OAuth Plugin for Opencode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Authenticate the Opencode CLI with your Google account so you can use your existing Gemini plan and its included quota instead of API billing.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Setup
|
|
6
6
|
|
|
7
|
-
Add the
|
|
7
|
+
1. Add the plugin to your [Opencode config](https://opencode.ai/docs/config/):
|
|
8
|
+
```json
|
|
9
|
+
{
|
|
10
|
+
"$schema": "https://opencode.ai/config.json",
|
|
11
|
+
"plugin": ["opencode-gemini-auth"]
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
2. Run `opencode auth login`.
|
|
15
|
+
3. Choose the Google provider and select **OAuth with Google (Gemini CLI)**.
|
|
8
16
|
|
|
9
|
-
|
|
10
|
-
{
|
|
11
|
-
"$schema": "https://opencode.ai/config.json",
|
|
12
|
-
"plugin": ["opencode-gemini-auth"]
|
|
13
|
-
}
|
|
14
|
-
```
|
|
17
|
+
The plugin spins up a local callback listener, so after approving in the browser you'll land on an "Authentication complete" page with no URL copy/paste required. If that port is already taken, the CLI automatically falls back to the classic copy/paste flow and explains what to do.
|
package/package.json
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
|
|
3
|
+
import { GEMINI_REDIRECT_URI } from "../constants";
|
|
4
|
+
|
|
5
|
+
interface OAuthListenerOptions {
|
|
6
|
+
/**
|
|
7
|
+
* How long to wait for the OAuth redirect before timing out (in milliseconds).
|
|
8
|
+
*/
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface OAuthListener {
|
|
13
|
+
/**
|
|
14
|
+
* Resolves with the callback URL once Google redirects back to the local server.
|
|
15
|
+
*/
|
|
16
|
+
waitForCallback(): Promise<URL>;
|
|
17
|
+
/**
|
|
18
|
+
* Cleanly stop listening for callbacks.
|
|
19
|
+
*/
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const redirectUri = new URL(GEMINI_REDIRECT_URI);
|
|
24
|
+
const callbackPath = redirectUri.pathname || "/";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Start a lightweight HTTP server that listens for the Gemini OAuth redirect.
|
|
28
|
+
* Returns a listener object that resolves with the callback once received.
|
|
29
|
+
*/
|
|
30
|
+
export async function startOAuthListener(
|
|
31
|
+
{ timeoutMs = 5 * 60 * 1000 }: OAuthListenerOptions = {},
|
|
32
|
+
): Promise<OAuthListener> {
|
|
33
|
+
const port = redirectUri.port
|
|
34
|
+
? Number.parseInt(redirectUri.port, 10)
|
|
35
|
+
: redirectUri.protocol === "https:"
|
|
36
|
+
? 443
|
|
37
|
+
: 80;
|
|
38
|
+
const origin = `${redirectUri.protocol}//${redirectUri.host}`;
|
|
39
|
+
|
|
40
|
+
let settled = false;
|
|
41
|
+
let resolveCallback: (url: URL) => void;
|
|
42
|
+
let rejectCallback: (error: Error) => void;
|
|
43
|
+
const callbackPromise = new Promise<URL>((resolve, reject) => {
|
|
44
|
+
resolveCallback = (url: URL) => {
|
|
45
|
+
if (settled) return;
|
|
46
|
+
settled = true;
|
|
47
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
48
|
+
resolve(url);
|
|
49
|
+
};
|
|
50
|
+
rejectCallback = (error: Error) => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
54
|
+
reject(error);
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const successResponse = `<!DOCTYPE html>
|
|
59
|
+
<html lang="en">
|
|
60
|
+
<head>
|
|
61
|
+
<meta charset="utf-8" />
|
|
62
|
+
<title>Opencode Gemini OAuth</title>
|
|
63
|
+
<style>
|
|
64
|
+
body { font-family: sans-serif; margin: 2rem; line-height: 1.5; }
|
|
65
|
+
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
|
|
66
|
+
</style>
|
|
67
|
+
</head>
|
|
68
|
+
<body>
|
|
69
|
+
<h1>Authentication complete</h1>
|
|
70
|
+
<p>You can close this tab and return to the Opencode CLI.</p>
|
|
71
|
+
</body>
|
|
72
|
+
</html>`;
|
|
73
|
+
|
|
74
|
+
const timeoutHandle = setTimeout(() => {
|
|
75
|
+
rejectCallback(new Error("Timed out waiting for OAuth callback"));
|
|
76
|
+
}, timeoutMs);
|
|
77
|
+
timeoutHandle.unref?.();
|
|
78
|
+
|
|
79
|
+
const server = createServer((request, response) => {
|
|
80
|
+
if (!request.url) {
|
|
81
|
+
response.writeHead(400, { "Content-Type": "text/plain" });
|
|
82
|
+
response.end("Invalid request");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const url = new URL(request.url, origin);
|
|
87
|
+
if (url.pathname !== callbackPath) {
|
|
88
|
+
response.writeHead(404, { "Content-Type": "text/plain" });
|
|
89
|
+
response.end("Not found");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
94
|
+
response.end(successResponse);
|
|
95
|
+
|
|
96
|
+
resolveCallback(url);
|
|
97
|
+
|
|
98
|
+
// Close the server after handling the first valid callback.
|
|
99
|
+
setImmediate(() => {
|
|
100
|
+
server.close();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await new Promise<void>((resolve, reject) => {
|
|
105
|
+
const handleError = (error: Error) => {
|
|
106
|
+
server.off("error", handleError);
|
|
107
|
+
reject(error);
|
|
108
|
+
};
|
|
109
|
+
server.once("error", handleError);
|
|
110
|
+
server.listen(port, "127.0.0.1", () => {
|
|
111
|
+
server.off("error", handleError);
|
|
112
|
+
resolve();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
server.on("error", (error) => {
|
|
117
|
+
rejectCallback(error instanceof Error ? error : new Error(String(error)));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
waitForCallback: () => callbackPromise,
|
|
122
|
+
close: () =>
|
|
123
|
+
new Promise<void>((resolve, reject) => {
|
|
124
|
+
server.close((error) => {
|
|
125
|
+
if (error && (error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") {
|
|
126
|
+
reject(error);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!settled) {
|
|
130
|
+
rejectCallback(new Error("OAuth listener closed before callback"));
|
|
131
|
+
}
|
|
132
|
+
resolve();
|
|
133
|
+
});
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
136
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { GEMINI_REDIRECT_URI } from "./constants";
|
|
1
2
|
import { authorizeGemini, exchangeGemini } from "./gemini/oauth";
|
|
2
3
|
import type { GeminiTokenExchangeResult } from "./gemini/oauth";
|
|
3
4
|
import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
|
|
@@ -5,6 +6,7 @@ import { promptProjectId } from "./plugin/cli";
|
|
|
5
6
|
import { ensureProjectContext } from "./plugin/project";
|
|
6
7
|
import { prepareGeminiRequest, transformGeminiResponse } from "./plugin/request";
|
|
7
8
|
import { refreshAccessToken } from "./plugin/token";
|
|
9
|
+
import { startOAuthListener, type OAuthListener } from "./plugin/server";
|
|
8
10
|
import type {
|
|
9
11
|
GetAuth,
|
|
10
12
|
LoaderResult,
|
|
@@ -74,15 +76,72 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
74
76
|
type: "oauth",
|
|
75
77
|
authorize: async () => {
|
|
76
78
|
console.log("\n=== Google Gemini OAuth Setup ===");
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
|
|
80
|
+
let listener: OAuthListener | null = null;
|
|
81
|
+
try {
|
|
82
|
+
listener = await startOAuthListener();
|
|
83
|
+
const { host } = new URL(GEMINI_REDIRECT_URI);
|
|
84
|
+
console.log("1. You'll be asked to sign in to your Google account and grant permission.");
|
|
85
|
+
console.log(
|
|
86
|
+
`2. We'll automatically capture the browser redirect on http://${host}. No need to paste anything back here.`,
|
|
87
|
+
);
|
|
88
|
+
console.log("3. Once you see the 'Authentication complete' page in your browser, return to this terminal.");
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.log("1. You'll be asked to sign in to your Google account and grant permission.");
|
|
91
|
+
console.log("2. After you approve, the browser will try to redirect to a 'localhost' page.");
|
|
92
|
+
console.log(
|
|
93
|
+
"3. This page will show an error like 'This site can’t be reached'. This is perfectly normal and means it worked!",
|
|
94
|
+
);
|
|
95
|
+
console.log(
|
|
96
|
+
"4. Once you see that error, copy the entire URL from the address bar, paste it back here, and press Enter.",
|
|
97
|
+
);
|
|
98
|
+
if (error instanceof Error) {
|
|
99
|
+
console.log(`\nWarning: Couldn't start the local callback listener (${error.message}). Falling back to manual copy/paste.`);
|
|
100
|
+
} else {
|
|
101
|
+
console.log("\nWarning: Couldn't start the local callback listener. Falling back to manual copy/paste.");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
console.log("\n");
|
|
82
105
|
|
|
83
106
|
const projectId = await promptProjectId();
|
|
84
107
|
const authorization = await authorizeGemini(projectId);
|
|
85
108
|
|
|
109
|
+
if (listener) {
|
|
110
|
+
return {
|
|
111
|
+
url: authorization.url,
|
|
112
|
+
instructions:
|
|
113
|
+
"Complete the sign-in flow in your browser. We'll automatically detect the redirect back to localhost.",
|
|
114
|
+
method: "auto",
|
|
115
|
+
callback: async (): Promise<GeminiTokenExchangeResult> => {
|
|
116
|
+
try {
|
|
117
|
+
const callbackUrl = await listener.waitForCallback();
|
|
118
|
+
const code = callbackUrl.searchParams.get("code");
|
|
119
|
+
const state = callbackUrl.searchParams.get("state");
|
|
120
|
+
|
|
121
|
+
if (!code || !state) {
|
|
122
|
+
return {
|
|
123
|
+
type: "failed",
|
|
124
|
+
error: "Missing code or state in callback URL",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return await exchangeGemini(code, state);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return {
|
|
131
|
+
type: "failed",
|
|
132
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
133
|
+
};
|
|
134
|
+
} finally {
|
|
135
|
+
try {
|
|
136
|
+
await listener?.close();
|
|
137
|
+
} catch {
|
|
138
|
+
// Ignore close errors.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
86
145
|
return {
|
|
87
146
|
url: authorization.url,
|
|
88
147
|
instructions:
|