opencode-gemini-auth 1.0.10 → 1.1.0

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 CHANGED
@@ -41,6 +41,14 @@ opencode # Reinstalls latest
41
41
 
42
42
  ## Local Development
43
43
 
44
+ First, clone the repository and install dependencies:
45
+
46
+ ```bash
47
+ git clone https://github.com/jenslys/opencode-gemini-auth.git
48
+ cd opencode-gemini-auth
49
+ bun install
50
+ ```
51
+
44
52
  When you want Opencode to use a local checkout of this plugin, point the
45
53
  `plugin` entry in your config to the folder via a `file://` URL:
46
54
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-gemini-auth",
3
3
  "module": "index.ts",
4
- "version": "1.0.10",
4
+ "version": "1.1.0",
5
5
  "author": "jenslys",
6
6
  "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
7
  "files": [
package/src/constants.ts CHANGED
@@ -32,3 +32,8 @@ export const CODE_ASSIST_HEADERS = {
32
32
  "X-Goog-Api-Client": "gl-node/22.17.0",
33
33
  "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
34
34
  } as const;
35
+
36
+ /**
37
+ * Provider identifier shared between the plugin loader and credential store.
38
+ */
39
+ export const GEMINI_PROVIDER_ID = "google";
@@ -0,0 +1,53 @@
1
+ import { accessTokenExpired } from "./auth";
2
+ import type { OAuthAuthDetails } from "./types";
3
+
4
+ const authCache = new Map<string, OAuthAuthDetails>();
5
+
6
+ function normalizeRefreshKey(refresh?: string): string | undefined {
7
+ const key = refresh?.trim();
8
+ return key ? key : undefined;
9
+ }
10
+
11
+ export function resolveCachedAuth(auth: OAuthAuthDetails): OAuthAuthDetails {
12
+ const key = normalizeRefreshKey(auth.refresh);
13
+ if (!key) {
14
+ return auth;
15
+ }
16
+
17
+ const cached = authCache.get(key);
18
+ if (!cached) {
19
+ authCache.set(key, auth);
20
+ return auth;
21
+ }
22
+
23
+ if (!accessTokenExpired(auth)) {
24
+ authCache.set(key, auth);
25
+ return auth;
26
+ }
27
+
28
+ if (!accessTokenExpired(cached)) {
29
+ return cached;
30
+ }
31
+
32
+ authCache.set(key, auth);
33
+ return auth;
34
+ }
35
+
36
+ export function storeCachedAuth(auth: OAuthAuthDetails): void {
37
+ const key = normalizeRefreshKey(auth.refresh);
38
+ if (!key) {
39
+ return;
40
+ }
41
+ authCache.set(key, auth);
42
+ }
43
+
44
+ export function clearCachedAuth(refresh?: string): void {
45
+ if (!refresh) {
46
+ authCache.clear();
47
+ return;
48
+ }
49
+ const key = normalizeRefreshKey(refresh);
50
+ if (key) {
51
+ authCache.delete(key);
52
+ }
53
+ }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  CODE_ASSIST_HEADERS,
3
3
  GEMINI_CODE_ASSIST_ENDPOINT,
4
+ GEMINI_PROVIDER_ID,
4
5
  } from "../constants";
5
6
  import { formatRefreshParts, parseRefreshParts } from "./auth";
6
7
  import type {
@@ -12,6 +13,73 @@ import type {
12
13
  const projectContextResultCache = new Map<string, ProjectContextResult>();
13
14
  const projectContextPendingCache = new Map<string, Promise<ProjectContextResult>>();
14
15
 
16
+ const CODE_ASSIST_METADATA = {
17
+ ideType: "IDE_UNSPECIFIED",
18
+ platform: "PLATFORM_UNSPECIFIED",
19
+ pluginType: "GEMINI",
20
+ } as const;
21
+
22
+ interface GeminiUserTier {
23
+ id?: string;
24
+ isDefault?: boolean;
25
+ userDefinedCloudaicompanionProject?: boolean;
26
+ }
27
+
28
+ interface LoadCodeAssistPayload {
29
+ cloudaicompanionProject?: string;
30
+ currentTier?: {
31
+ id?: string;
32
+ };
33
+ allowedTiers?: GeminiUserTier[];
34
+ }
35
+
36
+ interface OnboardUserPayload {
37
+ done?: boolean;
38
+ response?: {
39
+ cloudaicompanionProject?: {
40
+ id?: string;
41
+ };
42
+ };
43
+ }
44
+
45
+ class ProjectIdRequiredError extends Error {
46
+ constructor() {
47
+ super(
48
+ "Google Gemini requires a Google Cloud project. Enable the Gemini for Google Cloud API on a project you control, rerun `opencode auth login`, and supply that project ID when prompted.",
49
+ );
50
+ }
51
+ }
52
+
53
+ function buildMetadata(projectId?: string): Record<string, string> {
54
+ const metadata: Record<string, string> = {
55
+ ideType: CODE_ASSIST_METADATA.ideType,
56
+ platform: CODE_ASSIST_METADATA.platform,
57
+ pluginType: CODE_ASSIST_METADATA.pluginType,
58
+ };
59
+ if (projectId) {
60
+ metadata.duetProject = projectId;
61
+ }
62
+ return metadata;
63
+ }
64
+
65
+ function getDefaultTierId(allowedTiers?: GeminiUserTier[]): string | undefined {
66
+ if (!allowedTiers || allowedTiers.length === 0) {
67
+ return undefined;
68
+ }
69
+ for (const tier of allowedTiers) {
70
+ if (tier?.isDefault) {
71
+ return tier.id;
72
+ }
73
+ }
74
+ return allowedTiers[0]?.id;
75
+ }
76
+
77
+ function wait(ms: number): Promise<void> {
78
+ return new Promise(function (resolve) {
79
+ setTimeout(resolve, ms);
80
+ });
81
+ }
82
+
15
83
  function getCacheKey(auth: OAuthAuthDetails): string | undefined {
16
84
  const refresh = auth.refresh?.trim();
17
85
  return refresh ? refresh : undefined;
@@ -27,11 +95,18 @@ export function invalidateProjectContextCache(refresh?: string): void {
27
95
  projectContextResultCache.delete(refresh);
28
96
  }
29
97
 
30
- export async function loadManagedProject(accessToken: string): Promise<{
31
- managedProjectId?: string;
32
- needsOnboarding: boolean;
33
- }> {
98
+ export async function loadManagedProject(
99
+ accessToken: string,
100
+ projectId?: string,
101
+ ): Promise<LoadCodeAssistPayload | null> {
34
102
  try {
103
+ const metadata = buildMetadata(projectId);
104
+
105
+ const requestBody: Record<string, unknown> = { metadata };
106
+ if (projectId) {
107
+ requestBody.cloudaicompanionProject = projectId;
108
+ }
109
+
35
110
  const response = await fetch(
36
111
  `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`,
37
112
  {
@@ -41,134 +116,78 @@ export async function loadManagedProject(accessToken: string): Promise<{
41
116
  Authorization: `Bearer ${accessToken}`,
42
117
  ...CODE_ASSIST_HEADERS,
43
118
  },
44
- body: JSON.stringify({
45
- metadata: {
46
- ideType: "IDE_UNSPECIFIED",
47
- platform: "PLATFORM_UNSPECIFIED",
48
- pluginType: "GEMINI",
49
- },
50
- }),
119
+ body: JSON.stringify(requestBody),
51
120
  },
52
121
  );
53
122
 
54
123
  if (!response.ok) {
55
- return { needsOnboarding: false };
124
+ return null;
56
125
  }
57
126
 
58
- const payload = (await response.json()) as {
59
- cloudaicompanionProject?: string;
60
- currentTier?: string;
61
- };
62
-
63
- if (payload.cloudaicompanionProject) {
64
- return {
65
- managedProjectId: payload.cloudaicompanionProject,
66
- needsOnboarding: false,
67
- };
68
- }
69
-
70
- return { needsOnboarding: !payload.currentTier };
127
+ return (await response.json()) as LoadCodeAssistPayload;
71
128
  } catch (error) {
72
129
  console.error("Failed to load Gemini managed project:", error);
73
- return { needsOnboarding: false };
130
+ return null;
74
131
  }
75
132
  }
76
133
 
77
- export async function pollOperation(
134
+
135
+ export async function onboardManagedProject(
78
136
  accessToken: string,
79
- operationName: string,
137
+ tierId: string,
138
+ projectId?: string,
80
139
  attempts = 10,
81
- intervalMs = 2000,
140
+ delayMs = 5000,
82
141
  ): Promise<string | undefined> {
83
- for (let attempt = 0; attempt < attempts; attempt += 1) {
84
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
142
+ const metadata = buildMetadata(projectId);
143
+ const requestBody: Record<string, unknown> = {
144
+ tierId,
145
+ metadata,
146
+ };
85
147
 
148
+ if (tierId !== "FREE") {
149
+ if (!projectId) {
150
+ throw new ProjectIdRequiredError();
151
+ }
152
+ requestBody.cloudaicompanionProject = projectId;
153
+ }
154
+
155
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
86
156
  try {
87
157
  const response = await fetch(
88
- `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal/operations/${operationName}`,
158
+ `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`,
89
159
  {
90
- method: "GET",
160
+ method: "POST",
91
161
  headers: {
162
+ "Content-Type": "application/json",
92
163
  Authorization: `Bearer ${accessToken}`,
164
+ ...CODE_ASSIST_HEADERS,
93
165
  },
166
+ body: JSON.stringify(requestBody),
94
167
  },
95
168
  );
96
169
 
97
170
  if (!response.ok) {
98
- continue;
171
+ return undefined;
99
172
  }
100
173
 
101
- const payload = (await response.json()) as {
102
- done?: boolean;
103
- response?: {
104
- cloudaicompanionProject?: {
105
- id?: string;
106
- };
107
- };
108
- };
109
-
110
- const projectId = payload.response?.cloudaicompanionProject?.id;
174
+ const payload = (await response.json()) as OnboardUserPayload;
175
+ const managedProjectId = payload.response?.cloudaicompanionProject?.id;
176
+ if (payload.done && managedProjectId) {
177
+ return managedProjectId;
178
+ }
111
179
  if (payload.done && projectId) {
112
180
  return projectId;
113
181
  }
114
182
  } catch (error) {
115
- console.error("Failed to poll Gemini onboarding operation:", error);
116
- }
117
- }
118
- return undefined;
119
- }
120
-
121
- export async function onboardManagedProject(
122
- accessToken: string,
123
- ): Promise<string | undefined> {
124
- try {
125
- const response = await fetch(
126
- `${GEMINI_CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`,
127
- {
128
- method: "POST",
129
- headers: {
130
- "Content-Type": "application/json",
131
- Authorization: `Bearer ${accessToken}`,
132
- ...CODE_ASSIST_HEADERS,
133
- },
134
- body: JSON.stringify({
135
- tierId: "FREE",
136
- metadata: {
137
- ideType: "IDE_UNSPECIFIED",
138
- platform: "PLATFORM_UNSPECIFIED",
139
- pluginType: "GEMINI",
140
- },
141
- }),
142
- },
143
- );
144
-
145
- if (!response.ok) {
183
+ console.error("Failed to onboard Gemini managed project:", error);
146
184
  return undefined;
147
185
  }
148
186
 
149
- const payload = (await response.json()) as {
150
- done?: boolean;
151
- name?: string;
152
- response?: {
153
- cloudaicompanionProject?: {
154
- id?: string;
155
- };
156
- };
157
- };
158
-
159
- if (payload.done && payload.response?.cloudaicompanionProject?.id) {
160
- return payload.response.cloudaicompanionProject.id;
161
- }
162
-
163
- if (!payload.done && payload.name) {
164
- return pollOperation(accessToken, payload.name);
165
- }
166
-
167
- return undefined;
168
- } catch (error) {
169
- console.error("Failed to onboard Gemini managed project:", error);
170
- return undefined;
187
+ await wait(delayMs);
171
188
  }
189
+
190
+ return undefined;
172
191
  }
173
192
 
174
193
  export async function ensureProjectContext(
@@ -201,13 +220,43 @@ export async function ensureProjectContext(
201
220
  };
202
221
  }
203
222
 
204
- const loadResult = await loadManagedProject(accessToken);
205
- let managedProjectId = loadResult.managedProjectId;
223
+ const loadPayload = await loadManagedProject(accessToken, parts.projectId);
224
+ if (loadPayload?.cloudaicompanionProject) {
225
+ const managedProjectId = loadPayload.cloudaicompanionProject;
226
+ const updatedAuth: OAuthAuthDetails = {
227
+ ...auth,
228
+ refresh: formatRefreshParts({
229
+ refreshToken: parts.refreshToken,
230
+ projectId: parts.projectId,
231
+ managedProjectId,
232
+ }),
233
+ };
234
+
235
+ await client.auth.set({
236
+ path: { id: GEMINI_PROVIDER_ID },
237
+ body: updatedAuth,
238
+ });
239
+
240
+ return { auth: updatedAuth, effectiveProjectId: managedProjectId };
241
+ }
242
+
243
+ if (!loadPayload) {
244
+ throw new ProjectIdRequiredError();
245
+ }
246
+
247
+ const currentTierId = loadPayload.currentTier?.id ?? undefined;
248
+ if (currentTierId && currentTierId !== "FREE") {
249
+ throw new ProjectIdRequiredError();
250
+ }
251
+
252
+ const defaultTierId = getDefaultTierId(loadPayload.allowedTiers);
253
+ const tierId = defaultTierId ?? "FREE";
206
254
 
207
- if (!managedProjectId && loadResult.needsOnboarding) {
208
- managedProjectId = await onboardManagedProject(accessToken);
255
+ if (tierId !== "FREE") {
256
+ throw new ProjectIdRequiredError();
209
257
  }
210
258
 
259
+ const managedProjectId = await onboardManagedProject(accessToken, tierId, parts.projectId);
211
260
  if (managedProjectId) {
212
261
  const updatedAuth: OAuthAuthDetails = {
213
262
  ...auth,
@@ -219,14 +268,14 @@ export async function ensureProjectContext(
219
268
  };
220
269
 
221
270
  await client.auth.set({
222
- path: { id: "gemini-cli" },
271
+ path: { id: GEMINI_PROVIDER_ID },
223
272
  body: updatedAuth,
224
273
  });
225
274
 
226
275
  return { auth: updatedAuth, effectiveProjectId: managedProjectId };
227
276
  }
228
277
 
229
- return { auth, effectiveProjectId: "" };
278
+ throw new ProjectIdRequiredError();
230
279
  };
231
280
 
232
281
  if (!cacheKey) {
@@ -0,0 +1,74 @@
1
+ import { beforeEach, describe, expect, it, mock } from "bun:test";
2
+
3
+ import { GEMINI_PROVIDER_ID } from "../constants";
4
+ import { refreshAccessToken } from "./token";
5
+ import type { OAuthAuthDetails, PluginClient } from "./types";
6
+
7
+ const baseAuth: OAuthAuthDetails = {
8
+ type: "oauth",
9
+ refresh: "refresh-token|project-123",
10
+ access: "old-access",
11
+ expires: Date.now() - 1000,
12
+ };
13
+
14
+ function createClient() {
15
+ return {
16
+ auth: {
17
+ set: mock(async () => {}),
18
+ },
19
+ } as PluginClient & {
20
+ auth: { set: ReturnType<typeof mock<(input: any) => Promise<void>>> };
21
+ };
22
+ }
23
+
24
+ describe("refreshAccessToken", () => {
25
+ beforeEach(() => {
26
+ mock.restore();
27
+ });
28
+
29
+ it("updates the caller but skips persisting when refresh token is unchanged", async () => {
30
+ const client = createClient();
31
+ const fetchMock = mock(async () => {
32
+ return new Response(
33
+ JSON.stringify({
34
+ access_token: "new-access",
35
+ expires_in: 3600,
36
+ }),
37
+ { status: 200 },
38
+ );
39
+ });
40
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
41
+
42
+ const result = await refreshAccessToken(baseAuth, client);
43
+
44
+ expect(result?.access).toBe("new-access");
45
+ expect(client.auth.set.mock.calls.length).toBe(0);
46
+ });
47
+
48
+ it("persists when Google rotates the refresh token", async () => {
49
+ const client = createClient();
50
+ const fetchMock = mock(async () => {
51
+ return new Response(
52
+ JSON.stringify({
53
+ access_token: "next-access",
54
+ expires_in: 3600,
55
+ refresh_token: "rotated-token",
56
+ }),
57
+ { status: 200 },
58
+ );
59
+ });
60
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
61
+
62
+ const result = await refreshAccessToken(baseAuth, client);
63
+
64
+ expect(result?.access).toBe("next-access");
65
+ expect(client.auth.set.mock.calls.length).toBe(1);
66
+ expect(client.auth.set.mock.calls[0]?.[0]).toEqual({
67
+ path: { id: GEMINI_PROVIDER_ID },
68
+ body: expect.objectContaining({
69
+ type: "oauth",
70
+ refresh: expect.stringContaining("rotated-token"),
71
+ }),
72
+ });
73
+ });
74
+ });
@@ -1,5 +1,10 @@
1
- import { GEMINI_CLIENT_ID, GEMINI_CLIENT_SECRET } from "../constants";
1
+ import {
2
+ GEMINI_CLIENT_ID,
3
+ GEMINI_CLIENT_SECRET,
4
+ GEMINI_PROVIDER_ID,
5
+ } from "../constants";
2
6
  import { formatRefreshParts, parseRefreshParts } from "./auth";
7
+ import { clearCachedAuth, storeCachedAuth } from "./cache";
3
8
  import { invalidateProjectContextCache } from "./project";
4
9
  import type { OAuthAuthDetails, PluginClient, RefreshParts } from "./types";
5
10
 
@@ -101,7 +106,7 @@ export async function refreshAccessToken(
101
106
  }),
102
107
  };
103
108
  await client.auth.set({
104
- path: { id: "gemini-cli" },
109
+ path: { id: GEMINI_PROVIDER_ID },
105
110
  body: clearedAuth,
106
111
  });
107
112
  } catch (storeError) {
@@ -131,12 +136,19 @@ export async function refreshAccessToken(
131
136
  refresh: formatRefreshParts(refreshedParts),
132
137
  };
133
138
 
134
- await client.auth.set({
135
- path: { id: "gemini-cli" },
136
- body: updatedAuth,
137
- });
139
+ storeCachedAuth(updatedAuth);
138
140
  invalidateProjectContextCache(auth.refresh);
139
141
 
142
+ const refreshTokenRotated =
143
+ typeof payload.refresh_token === "string" && payload.refresh_token !== parts.refreshToken;
144
+
145
+ if (refreshTokenRotated) {
146
+ await client.auth.set({
147
+ path: { id: GEMINI_PROVIDER_ID },
148
+ body: updatedAuth,
149
+ });
150
+ }
151
+
140
152
  return updatedAuth;
141
153
  } catch (error) {
142
154
  console.error("Failed to refresh Gemini access token due to an unexpected error:", error);
package/src/plugin.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { GEMINI_REDIRECT_URI } from "./constants";
1
+ import { GEMINI_PROVIDER_ID, GEMINI_REDIRECT_URI } from "./constants";
2
2
  import { authorizeGemini, exchangeGemini } from "./gemini/oauth";
3
3
  import type { GeminiTokenExchangeResult } from "./gemini/oauth";
4
4
  import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
@@ -17,6 +17,7 @@ import type {
17
17
  LoaderResult,
18
18
  PluginContext,
19
19
  PluginResult,
20
+ ProjectContextResult,
20
21
  Provider,
21
22
  } from "./plugin/types";
22
23
 
@@ -24,7 +25,7 @@ export const GeminiCLIOAuthPlugin = async (
24
25
  { client }: PluginContext,
25
26
  ): Promise<PluginResult> => ({
26
27
  auth: {
27
- provider: "google",
28
+ provider: GEMINI_PROVIDER_ID,
28
29
  loader: async (getAuth: GetAuth, provider: Provider): Promise<LoaderResult | null> => {
29
30
  const auth = await getAuth();
30
31
  if (!isOAuthAuth(auth)) {
@@ -65,7 +66,18 @@ export const GeminiCLIOAuthPlugin = async (
65
66
  return fetch(input, init);
66
67
  }
67
68
 
68
- const projectContext = await ensureProjectContext(authRecord, client);
69
+ async function resolveProjectContext(): Promise<ProjectContextResult> {
70
+ try {
71
+ return await ensureProjectContext(authRecord, client);
72
+ } catch (error) {
73
+ if (error instanceof Error) {
74
+ console.error(error.message);
75
+ }
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ const projectContext = await resolveProjectContext();
69
81
 
70
82
  const { request, init: transformedInit, streaming } = prepareGeminiRequest(
71
83
  input,
@@ -193,7 +205,7 @@ export const GeminiCLIOAuthPlugin = async (
193
205
  },
194
206
  },
195
207
  {
196
- provider: "google",
208
+ provider: GEMINI_PROVIDER_ID,
197
209
  label: "Manually enter API Key",
198
210
  type: "api",
199
211
  },