opencode-gemini-auth 1.0.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # Gemini OAuth Plugin for Opencode
2
+
3
+ Plugin for Opencode that allows you to authenticate with your Google account. This allows you to use your Google account instead of using the API.
4
+
5
+ ## install
6
+
7
+ Add the `opencode-gemini-auth` plugin to your [opencode config](https://opencode.ai/docs/config/)
8
+
9
+ ```json
10
+ {
11
+ "$schema": "https://opencode.ai/config.json",
12
+ "plugin": ["opencode-gemini-auth"]
13
+ }
14
+ ```
package/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export {
2
+ GeminiCLIOAuthPlugin,
3
+ GoogleOAuthPlugin,
4
+ } from "./src/plugin";
5
+
6
+ export {
7
+ authorizeGemini,
8
+ exchangeGemini,
9
+ } from "./src/gemini/oauth";
10
+
11
+ export type {
12
+ GeminiAuthorization,
13
+ GeminiTokenExchangeResult,
14
+ } from "./src/gemini/oauth";
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "opencode-gemini-auth",
3
+ "module": "index.ts",
4
+ "version": "1.0.5",
5
+ "author": "jenslys",
6
+ "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
+ "files": [
8
+ "index.ts",
9
+ "src"
10
+ ],
11
+ "license": "MIT",
12
+ "type": "module",
13
+ "devDependencies": {
14
+ "@opencode-ai/plugin": "^0.15.30",
15
+ "@types/bun": "latest"
16
+ },
17
+ "peerDependencies": {
18
+ "typescript": "^5"
19
+ },
20
+ "dependencies": {
21
+ "@openauthjs/openauth": "^0.4.3"
22
+ }
23
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Constants used for Google Gemini OAuth flows and Cloud Code Assist API integration.
3
+ */
4
+ export const GEMINI_CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
5
+
6
+ /**
7
+ * Client secret issued for the Gemini CLI OAuth application.
8
+ */
9
+ export const GEMINI_CLIENT_SECRET = "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";
10
+
11
+ /**
12
+ * Scopes required for Gemini CLI integrations.
13
+ */
14
+ export const GEMINI_SCOPES: readonly string[] = [
15
+ "https://www.googleapis.com/auth/cloud-platform",
16
+ "https://www.googleapis.com/auth/userinfo.email",
17
+ "https://www.googleapis.com/auth/userinfo.profile",
18
+ ];
19
+
20
+ /**
21
+ * OAuth redirect URI used by the local CLI callback server.
22
+ */
23
+ export const GEMINI_REDIRECT_URI = "http://localhost:8085/oauth2callback";
24
+
25
+ /**
26
+ * Root endpoint for the Cloud Code Assist API which backs Gemini CLI traffic.
27
+ */
28
+ export const GEMINI_CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
29
+
30
+ export const CODE_ASSIST_HEADERS = {
31
+ "User-Agent": "google-api-nodejs-client/9.15.1",
32
+ "X-Goog-Api-Client": "gl-node/22.17.0",
33
+ "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
34
+ } as const;
@@ -0,0 +1,174 @@
1
+ import { generatePKCE } from "@openauthjs/openauth/pkce";
2
+
3
+ import {
4
+ GEMINI_CLIENT_ID,
5
+ GEMINI_CLIENT_SECRET,
6
+ GEMINI_REDIRECT_URI,
7
+ GEMINI_SCOPES,
8
+ } from "../constants";
9
+
10
+ interface PkcePair {
11
+ challenge: string;
12
+ verifier: string;
13
+ }
14
+
15
+ interface GeminiAuthState {
16
+ verifier: string;
17
+ projectId: string;
18
+ }
19
+
20
+ /**
21
+ * Result returned to the caller after constructing an OAuth authorization URL.
22
+ */
23
+ export interface GeminiAuthorization {
24
+ url: string;
25
+ verifier: string;
26
+ projectId: string;
27
+ }
28
+
29
+ interface GeminiTokenExchangeSuccess {
30
+ type: "success";
31
+ refresh: string;
32
+ access: string;
33
+ expires: number;
34
+ email?: string;
35
+ projectId: string;
36
+ }
37
+
38
+ interface GeminiTokenExchangeFailure {
39
+ type: "failed";
40
+ error: string;
41
+ }
42
+
43
+ export type GeminiTokenExchangeResult =
44
+ | GeminiTokenExchangeSuccess
45
+ | GeminiTokenExchangeFailure;
46
+
47
+ interface GeminiTokenResponse {
48
+ access_token: string;
49
+ expires_in: number;
50
+ refresh_token: string;
51
+ }
52
+
53
+ interface GeminiUserInfo {
54
+ email?: string;
55
+ }
56
+
57
+ /**
58
+ * Encode an object into a URL-safe base64 string.
59
+ */
60
+ function encodeState(payload: GeminiAuthState): string {
61
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
62
+ }
63
+
64
+ /**
65
+ * Decode an OAuth state parameter back into its structured representation.
66
+ */
67
+ function decodeState(state: string): GeminiAuthState {
68
+ const normalized = state.replace(/-/g, "+").replace(/_/g, "/");
69
+ const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), "=");
70
+ const json = Buffer.from(padded, "base64").toString("utf8");
71
+ const parsed = JSON.parse(json);
72
+ if (typeof parsed.verifier !== "string") {
73
+ throw new Error("Missing PKCE verifier in state");
74
+ }
75
+ return {
76
+ verifier: parsed.verifier,
77
+ projectId: typeof parsed.projectId === "string" ? parsed.projectId : "",
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Build the Gemini OAuth authorization URL including PKCE and optional project metadata.
83
+ */
84
+ export async function authorizeGemini(projectId = ""): Promise<GeminiAuthorization> {
85
+ const pkce = (await generatePKCE()) as PkcePair;
86
+
87
+ const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
88
+ url.searchParams.set("client_id", GEMINI_CLIENT_ID);
89
+ url.searchParams.set("response_type", "code");
90
+ url.searchParams.set("redirect_uri", GEMINI_REDIRECT_URI);
91
+ url.searchParams.set("scope", GEMINI_SCOPES.join(" "));
92
+ url.searchParams.set("code_challenge", pkce.challenge);
93
+ url.searchParams.set("code_challenge_method", "S256");
94
+ url.searchParams.set(
95
+ "state",
96
+ encodeState({ verifier: pkce.verifier, projectId: projectId || "" }),
97
+ );
98
+ url.searchParams.set("access_type", "offline");
99
+ url.searchParams.set("prompt", "consent");
100
+
101
+ return {
102
+ url: url.toString(),
103
+ verifier: pkce.verifier,
104
+ projectId: projectId || "",
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Exchange an authorization code for Gemini CLI access and refresh tokens.
110
+ */
111
+ export async function exchangeGemini(
112
+ code: string,
113
+ state: string,
114
+ ): Promise<GeminiTokenExchangeResult> {
115
+ try {
116
+ const { verifier, projectId } = decodeState(state);
117
+
118
+ const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
119
+ method: "POST",
120
+ headers: {
121
+ "Content-Type": "application/x-www-form-urlencoded",
122
+ },
123
+ body: new URLSearchParams({
124
+ client_id: GEMINI_CLIENT_ID,
125
+ client_secret: GEMINI_CLIENT_SECRET,
126
+ code,
127
+ grant_type: "authorization_code",
128
+ redirect_uri: GEMINI_REDIRECT_URI,
129
+ code_verifier: verifier,
130
+ }),
131
+ });
132
+
133
+ if (!tokenResponse.ok) {
134
+ const errorText = await tokenResponse.text();
135
+ return { type: "failed", error: errorText };
136
+ }
137
+
138
+ const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
139
+
140
+ const userInfoResponse = await fetch(
141
+ "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
142
+ {
143
+ headers: {
144
+ Authorization: `Bearer ${tokenPayload.access_token}`,
145
+ },
146
+ },
147
+ );
148
+
149
+ const userInfo = userInfoResponse.ok
150
+ ? ((await userInfoResponse.json()) as GeminiUserInfo)
151
+ : {};
152
+
153
+ const refreshToken = tokenPayload.refresh_token;
154
+ if (!refreshToken) {
155
+ return { type: "failed", error: "Missing refresh token in response" };
156
+ }
157
+
158
+ const storedRefresh = `${refreshToken}|${projectId || ""}`;
159
+
160
+ return {
161
+ type: "success",
162
+ refresh: storedRefresh,
163
+ access: tokenPayload.access_token,
164
+ expires: Date.now() + tokenPayload.expires_in * 1000,
165
+ email: userInfo.email,
166
+ projectId: projectId || "",
167
+ };
168
+ } catch (error) {
169
+ return {
170
+ type: "failed",
171
+ error: error instanceof Error ? error.message : "Unknown error",
172
+ };
173
+ }
174
+ }
@@ -0,0 +1,27 @@
1
+ import type { AuthDetails, OAuthAuthDetails, RefreshParts } from "./types";
2
+
3
+ export function isOAuthAuth(auth: AuthDetails): auth is OAuthAuthDetails {
4
+ return auth.type === "oauth";
5
+ }
6
+
7
+ export function parseRefreshParts(refresh: string): RefreshParts {
8
+ const [refreshToken = "", projectId = "", managedProjectId = ""] = (refresh ?? "").split("|");
9
+ return {
10
+ refreshToken,
11
+ projectId: projectId || undefined,
12
+ managedProjectId: managedProjectId || undefined,
13
+ };
14
+ }
15
+
16
+ export function formatRefreshParts(parts: RefreshParts): string {
17
+ const projectSegment = parts.projectId ?? "";
18
+ const base = `${parts.refreshToken}|${projectSegment}`;
19
+ return parts.managedProjectId ? `${base}|${parts.managedProjectId}` : base;
20
+ }
21
+
22
+ export function accessTokenExpired(auth: OAuthAuthDetails): boolean {
23
+ if (!auth.access || typeof auth.expires !== "number") {
24
+ return true;
25
+ }
26
+ return auth.expires <= Date.now();
27
+ }
@@ -0,0 +1,12 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+
4
+ export async function promptProjectId(): Promise<string> {
5
+ const rl = createInterface({ input, output });
6
+ try {
7
+ const answer = await rl.question("Project ID (leave blank to use your default project): ");
8
+ return answer.trim();
9
+ } finally {
10
+ rl.close();
11
+ }
12
+ }
@@ -0,0 +1,198 @@
1
+ import {
2
+ CODE_ASSIST_HEADERS,
3
+ GEMINI_CODE_ASSIST_ENDPOINT,
4
+ } from "../constants";
5
+ import { formatRefreshParts, parseRefreshParts } from "./auth";
6
+ import type {
7
+ OAuthAuthDetails,
8
+ PluginClient,
9
+ ProjectContextResult,
10
+ } from "./types";
11
+
12
+ export async function loadManagedProject(accessToken: string): Promise<{
13
+ managedProjectId?: string;
14
+ needsOnboarding: boolean;
15
+ }> {
16
+ try {
17
+ const response = await fetch(
18
+ `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`,
19
+ {
20
+ method: "POST",
21
+ headers: {
22
+ "Content-Type": "application/json",
23
+ Authorization: `Bearer ${accessToken}`,
24
+ ...CODE_ASSIST_HEADERS,
25
+ },
26
+ body: JSON.stringify({
27
+ metadata: {
28
+ ideType: "IDE_UNSPECIFIED",
29
+ platform: "PLATFORM_UNSPECIFIED",
30
+ pluginType: "GEMINI",
31
+ },
32
+ }),
33
+ },
34
+ );
35
+
36
+ if (!response.ok) {
37
+ return { needsOnboarding: false };
38
+ }
39
+
40
+ const payload = (await response.json()) as {
41
+ cloudaicompanionProject?: string;
42
+ currentTier?: string;
43
+ };
44
+
45
+ if (payload.cloudaicompanionProject) {
46
+ return {
47
+ managedProjectId: payload.cloudaicompanionProject,
48
+ needsOnboarding: false,
49
+ };
50
+ }
51
+
52
+ return { needsOnboarding: !payload.currentTier };
53
+ } catch (error) {
54
+ console.error("Failed to load Gemini managed project:", error);
55
+ return { needsOnboarding: false };
56
+ }
57
+ }
58
+
59
+ export async function pollOperation(
60
+ accessToken: string,
61
+ operationName: string,
62
+ attempts = 10,
63
+ intervalMs = 2000,
64
+ ): Promise<string | undefined> {
65
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
66
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
67
+
68
+ try {
69
+ const response = await fetch(
70
+ `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal/operations/${operationName}`,
71
+ {
72
+ method: "GET",
73
+ headers: {
74
+ Authorization: `Bearer ${accessToken}`,
75
+ },
76
+ },
77
+ );
78
+
79
+ if (!response.ok) {
80
+ continue;
81
+ }
82
+
83
+ const payload = (await response.json()) as {
84
+ done?: boolean;
85
+ response?: {
86
+ cloudaicompanionProject?: {
87
+ id?: string;
88
+ };
89
+ };
90
+ };
91
+
92
+ const projectId = payload.response?.cloudaicompanionProject?.id;
93
+ if (payload.done && projectId) {
94
+ return projectId;
95
+ }
96
+ } catch (error) {
97
+ console.error("Failed to poll Gemini onboarding operation:", error);
98
+ }
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ export async function onboardManagedProject(
104
+ accessToken: string,
105
+ ): Promise<string | undefined> {
106
+ try {
107
+ const response = await fetch(
108
+ `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`,
109
+ {
110
+ method: "POST",
111
+ headers: {
112
+ "Content-Type": "application/json",
113
+ Authorization: `Bearer ${accessToken}`,
114
+ ...CODE_ASSIST_HEADERS,
115
+ },
116
+ body: JSON.stringify({
117
+ tierId: "FREE",
118
+ metadata: {
119
+ ideType: "IDE_UNSPECIFIED",
120
+ platform: "PLATFORM_UNSPECIFIED",
121
+ pluginType: "GEMINI",
122
+ },
123
+ }),
124
+ },
125
+ );
126
+
127
+ if (!response.ok) {
128
+ return undefined;
129
+ }
130
+
131
+ const payload = (await response.json()) as {
132
+ done?: boolean;
133
+ name?: string;
134
+ response?: {
135
+ cloudaicompanionProject?: {
136
+ id?: string;
137
+ };
138
+ };
139
+ };
140
+
141
+ if (payload.done && payload.response?.cloudaicompanionProject?.id) {
142
+ return payload.response.cloudaicompanionProject.id;
143
+ }
144
+
145
+ if (!payload.done && payload.name) {
146
+ return pollOperation(accessToken, payload.name);
147
+ }
148
+
149
+ return undefined;
150
+ } catch (error) {
151
+ console.error("Failed to onboard Gemini managed project:", error);
152
+ return undefined;
153
+ }
154
+ }
155
+
156
+ export async function ensureProjectContext(
157
+ auth: OAuthAuthDetails,
158
+ client: PluginClient,
159
+ ): Promise<ProjectContextResult> {
160
+ if (!auth.access) {
161
+ return { auth, effectiveProjectId: "" };
162
+ }
163
+
164
+ const parts = parseRefreshParts(auth.refresh);
165
+ if (parts.projectId || parts.managedProjectId) {
166
+ return {
167
+ auth,
168
+ effectiveProjectId: parts.projectId || parts.managedProjectId || "",
169
+ };
170
+ }
171
+
172
+ const loadResult = await loadManagedProject(auth.access);
173
+ let managedProjectId = loadResult.managedProjectId;
174
+
175
+ if (!managedProjectId && loadResult.needsOnboarding) {
176
+ managedProjectId = await onboardManagedProject(auth.access);
177
+ }
178
+
179
+ if (managedProjectId) {
180
+ const updatedAuth: OAuthAuthDetails = {
181
+ ...auth,
182
+ refresh: formatRefreshParts({
183
+ refreshToken: parts.refreshToken,
184
+ projectId: parts.projectId,
185
+ managedProjectId,
186
+ }),
187
+ };
188
+
189
+ await client.auth.set({
190
+ path: { id: "gemini-cli" },
191
+ body: updatedAuth,
192
+ });
193
+
194
+ return { auth: updatedAuth, effectiveProjectId: managedProjectId };
195
+ }
196
+
197
+ return { auth, effectiveProjectId: "" };
198
+ }
@@ -0,0 +1,146 @@
1
+ import {
2
+ CODE_ASSIST_HEADERS,
3
+ GEMINI_CODE_ASSIST_ENDPOINT,
4
+ } from "../constants";
5
+
6
+ const STREAM_ACTION = "streamGenerateContent";
7
+
8
+ function isGenerativeLanguageRequest(input: RequestInfo): input is string {
9
+ return typeof input === "string" && input.includes("generativelanguage.googleapis.com");
10
+ }
11
+
12
+ function transformStreamingPayload(payload: string): string {
13
+ return payload
14
+ .split("\n")
15
+ .map((line) => {
16
+ if (!line.startsWith("data:")) {
17
+ return line;
18
+ }
19
+ const json = line.slice(5).trim();
20
+ if (!json) {
21
+ return line;
22
+ }
23
+ try {
24
+ const parsed = JSON.parse(json) as { response?: unknown };
25
+ if (parsed.response !== undefined) {
26
+ return `data: ${JSON.stringify(parsed.response)}`;
27
+ }
28
+ } catch (_) {}
29
+ return line;
30
+ })
31
+ .join("\n");
32
+ }
33
+
34
+ export function prepareGeminiRequest(
35
+ input: RequestInfo,
36
+ init: RequestInit | undefined,
37
+ accessToken: string,
38
+ projectId: string,
39
+ ): { request: RequestInfo; init: RequestInit; streaming: boolean } {
40
+ const baseInit: RequestInit = { ...init };
41
+ const headers = new Headers(init?.headers ?? {});
42
+ headers.set("Authorization", `Bearer ${accessToken}`);
43
+ headers.delete("x-api-key");
44
+
45
+ if (!isGenerativeLanguageRequest(input)) {
46
+ return {
47
+ request: input,
48
+ init: { ...baseInit, headers },
49
+ streaming: false,
50
+ };
51
+ }
52
+
53
+ const match = input.match(/\/models\/([^:]+):(\w+)/);
54
+ if (!match) {
55
+ return {
56
+ request: input,
57
+ init: { ...baseInit, headers },
58
+ streaming: false,
59
+ };
60
+ }
61
+
62
+ const [, model, action] = match;
63
+ const streaming = action === STREAM_ACTION;
64
+ const transformedUrl = `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:${action}${
65
+ streaming ? "?alt=sse" : ""
66
+ }`;
67
+
68
+ let body = baseInit.body;
69
+ if (typeof baseInit.body === "string" && baseInit.body) {
70
+ try {
71
+ const parsedBody = JSON.parse(baseInit.body) as Record<string, unknown>;
72
+ const requestPayload: Record<string, unknown> = { ...parsedBody };
73
+
74
+ if ("system_instruction" in requestPayload) {
75
+ requestPayload.systemInstruction = requestPayload.system_instruction;
76
+ delete requestPayload.system_instruction;
77
+ }
78
+
79
+ if ("model" in requestPayload) {
80
+ delete requestPayload.model;
81
+ }
82
+
83
+ const wrappedBody = {
84
+ project: projectId,
85
+ model,
86
+ request: requestPayload,
87
+ };
88
+
89
+ body = JSON.stringify(wrappedBody);
90
+ } catch (error) {
91
+ console.error("Failed to transform Gemini request body:", error);
92
+ }
93
+ }
94
+
95
+ if (streaming) {
96
+ headers.set("Accept", "text/event-stream");
97
+ }
98
+
99
+ headers.set("User-Agent", CODE_ASSIST_HEADERS["User-Agent"]);
100
+ headers.set("X-Goog-Api-Client", CODE_ASSIST_HEADERS["X-Goog-Api-Client"]);
101
+ headers.set("Client-Metadata", CODE_ASSIST_HEADERS["Client-Metadata"]);
102
+
103
+ return {
104
+ request: transformedUrl,
105
+ init: {
106
+ ...baseInit,
107
+ headers,
108
+ body,
109
+ },
110
+ streaming,
111
+ };
112
+ }
113
+
114
+ export async function transformGeminiResponse(
115
+ response: Response,
116
+ streaming: boolean,
117
+ ): Promise<Response> {
118
+ const contentType = response.headers.get("content-type") ?? "";
119
+ if (!streaming && !contentType.includes("application/json")) {
120
+ return response;
121
+ }
122
+
123
+ try {
124
+ const text = await response.text();
125
+ const headers = new Headers(response.headers);
126
+ const init = {
127
+ status: response.status,
128
+ statusText: response.statusText,
129
+ headers,
130
+ };
131
+
132
+ if (streaming) {
133
+ return new Response(transformStreamingPayload(text), init);
134
+ }
135
+
136
+ const parsed = JSON.parse(text) as { response?: unknown };
137
+ if (parsed.response !== undefined) {
138
+ return new Response(JSON.stringify(parsed.response), init);
139
+ }
140
+
141
+ return new Response(text, init);
142
+ } catch (error) {
143
+ console.error("Failed to transform Gemini response:", error);
144
+ return response;
145
+ }
146
+ }
@@ -0,0 +1,61 @@
1
+ import { GEMINI_CLIENT_ID, GEMINI_CLIENT_SECRET } from "../constants";
2
+ import { formatRefreshParts, parseRefreshParts } from "./auth";
3
+ import type { OAuthAuthDetails, PluginClient, RefreshParts } from "./types";
4
+
5
+ export async function refreshAccessToken(
6
+ auth: OAuthAuthDetails,
7
+ client: PluginClient,
8
+ ): Promise<OAuthAuthDetails | undefined> {
9
+ const parts = parseRefreshParts(auth.refresh);
10
+ if (!parts.refreshToken) {
11
+ return undefined;
12
+ }
13
+
14
+ try {
15
+ const response = await fetch("https://oauth2.googleapis.com/token", {
16
+ method: "POST",
17
+ headers: {
18
+ "Content-Type": "application/x-www-form-urlencoded",
19
+ },
20
+ body: new URLSearchParams({
21
+ grant_type: "refresh_token",
22
+ refresh_token: parts.refreshToken,
23
+ client_id: GEMINI_CLIENT_ID,
24
+ client_secret: GEMINI_CLIENT_SECRET,
25
+ }),
26
+ });
27
+
28
+ if (!response.ok) {
29
+ return undefined;
30
+ }
31
+
32
+ const payload = (await response.json()) as {
33
+ access_token: string;
34
+ expires_in: number;
35
+ refresh_token?: string;
36
+ };
37
+
38
+ const refreshedParts: RefreshParts = {
39
+ refreshToken: payload.refresh_token ?? parts.refreshToken,
40
+ projectId: parts.projectId,
41
+ managedProjectId: parts.managedProjectId,
42
+ };
43
+
44
+ const updatedAuth: OAuthAuthDetails = {
45
+ ...auth,
46
+ access: payload.access_token,
47
+ expires: Date.now() + payload.expires_in * 1000,
48
+ refresh: formatRefreshParts(refreshedParts),
49
+ };
50
+
51
+ await client.auth.set({
52
+ path: { id: "gemini-cli" },
53
+ body: updatedAuth,
54
+ });
55
+
56
+ return updatedAuth;
57
+ } catch (error) {
58
+ console.error("Failed to refresh Gemini access token:", error);
59
+ return undefined;
60
+ }
61
+ }
@@ -0,0 +1,75 @@
1
+ import type { GeminiTokenExchangeResult } from "../gemini/oauth";
2
+
3
+ export interface OAuthAuthDetails {
4
+ type: "oauth";
5
+ refresh: string;
6
+ access?: string;
7
+ expires?: number;
8
+ }
9
+
10
+ export interface NonOAuthAuthDetails {
11
+ type: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ export type AuthDetails = OAuthAuthDetails | NonOAuthAuthDetails;
16
+
17
+ export type GetAuth = () => Promise<AuthDetails>;
18
+
19
+ export interface ProviderModel {
20
+ cost?: {
21
+ input: number;
22
+ output: number;
23
+ };
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ export interface Provider {
28
+ models?: Record<string, ProviderModel>;
29
+ }
30
+
31
+ export interface LoaderResult {
32
+ apiKey: string;
33
+ fetch(input: RequestInfo, init?: RequestInit): Promise<Response>;
34
+ }
35
+
36
+ export interface AuthMethod {
37
+ provider?: string;
38
+ label: string;
39
+ type: "oauth" | "api";
40
+ authorize?: () => Promise<{
41
+ url: string;
42
+ instructions: string;
43
+ method: string;
44
+ callback: (callbackUrl: string) => Promise<GeminiTokenExchangeResult>;
45
+ }>;
46
+ }
47
+
48
+ export interface PluginClient {
49
+ auth: {
50
+ set(input: { path: { id: string }; body: OAuthAuthDetails }): Promise<void>;
51
+ };
52
+ }
53
+
54
+ export interface PluginContext {
55
+ client: PluginClient;
56
+ }
57
+
58
+ export interface PluginResult {
59
+ auth: {
60
+ provider: string;
61
+ loader: (getAuth: GetAuth, provider: Provider) => Promise<LoaderResult | null>;
62
+ methods: AuthMethod[];
63
+ };
64
+ }
65
+
66
+ export interface RefreshParts {
67
+ refreshToken: string;
68
+ projectId?: string;
69
+ managedProjectId?: string;
70
+ }
71
+
72
+ export interface ProjectContextResult {
73
+ auth: OAuthAuthDetails;
74
+ effectiveProjectId: string;
75
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { authorizeGemini, exchangeGemini } from "./gemini/oauth";
2
+ import type { GeminiTokenExchangeResult } from "./gemini/oauth";
3
+ import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
4
+ import { promptProjectId } from "./plugin/cli";
5
+ import { ensureProjectContext } from "./plugin/project";
6
+ import { prepareGeminiRequest, transformGeminiResponse } from "./plugin/request";
7
+ import { refreshAccessToken } from "./plugin/token";
8
+ import type {
9
+ GetAuth,
10
+ LoaderResult,
11
+ PluginContext,
12
+ PluginResult,
13
+ Provider,
14
+ } from "./plugin/types";
15
+
16
+ export const GeminiCLIOAuthPlugin = async (
17
+ { client }: PluginContext,
18
+ ): Promise<PluginResult> => ({
19
+ auth: {
20
+ provider: "google",
21
+ loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | null> => {
22
+ const auth = await getAuth();
23
+ if (!isOAuthAuth(auth)) {
24
+ return null;
25
+ }
26
+
27
+ if (provider.models) {
28
+ for (const model of Object.values(provider.models)) {
29
+ if (model) {
30
+ model.cost = { input: 0, output: 0 };
31
+ }
32
+ }
33
+ }
34
+
35
+ return {
36
+ apiKey: "",
37
+ async fetch(input, init) {
38
+ const latestAuth = await getAuth();
39
+ if (!isOAuthAuth(latestAuth)) {
40
+ return fetch(input, init);
41
+ }
42
+
43
+ let authRecord = latestAuth;
44
+ if (accessTokenExpired(authRecord)) {
45
+ const refreshed = await refreshAccessToken(authRecord, client);
46
+ if (!refreshed) {
47
+ return fetch(input, init);
48
+ }
49
+ authRecord = refreshed;
50
+ }
51
+
52
+ const accessToken = authRecord.access;
53
+ if (!accessToken) {
54
+ return fetch(input, init);
55
+ }
56
+
57
+ const projectContext = await ensureProjectContext(authRecord, client);
58
+
59
+ const { request, init: transformedInit, streaming } = prepareGeminiRequest(
60
+ input,
61
+ init,
62
+ accessToken,
63
+ projectContext.effectiveProjectId,
64
+ );
65
+
66
+ const response = await fetch(request, transformedInit);
67
+ return transformGeminiResponse(response, streaming);
68
+ },
69
+ };
70
+ },
71
+ methods: [
72
+ {
73
+ label: "OAuth with Google (Gemini CLI)",
74
+ type: "oauth",
75
+ authorize: async () => {
76
+ console.log("\n=== Google Gemini OAuth Setup ===");
77
+ console.log("1. You'll be asked to sign in to your Google account and grant permission.");
78
+ console.log("2. After you approve, the browser will try to redirect to a 'localhost' page.");
79
+ console.log("3. This page will show an error like 'This site can’t be reached'. This is perfectly normal and means it worked!");
80
+ console.log("4. Once you see that error, copy the entire URL from the address bar, paste it back here, and press Enter.");
81
+ console.log("\n")
82
+
83
+ const projectId = await promptProjectId();
84
+ const authorization = await authorizeGemini(projectId);
85
+
86
+ return {
87
+ url: authorization.url,
88
+ instructions:
89
+ "Visit the URL above, complete OAuth, ignore the localhost connection error, and paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...): ",
90
+ method: "code",
91
+ callback: async (callbackUrl: string): Promise<GeminiTokenExchangeResult> => {
92
+ try {
93
+ const url = new URL(callbackUrl);
94
+ const code = url.searchParams.get("code");
95
+ const state = url.searchParams.get("state");
96
+
97
+ if (!code || !state) {
98
+ return {
99
+ type: "failed",
100
+ error: "Missing code or state in callback URL",
101
+ };
102
+ }
103
+
104
+ return exchangeGemini(code, state);
105
+ } catch (error) {
106
+ return {
107
+ type: "failed",
108
+ error: error instanceof Error ? error.message : "Unknown error",
109
+ };
110
+ }
111
+ },
112
+ };
113
+ },
114
+ },
115
+ {
116
+ provider: "google",
117
+ label: "Manually enter API Key",
118
+ type: "api",
119
+ },
120
+ ],
121
+ },
122
+ });
123
+
124
+ export const GoogleOAuthPlugin = GeminiCLIOAuthPlugin;
package/src/shims.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ declare module "@openauthjs/openauth/pkce" {
2
+ interface PkcePair {
3
+ challenge: string;
4
+ verifier: string;
5
+ }
6
+
7
+ export function generatePKCE(): Promise<PkcePair>;
8
+ }