opencode-gemini-auth 1.3.8 → 1.3.9

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
@@ -24,6 +24,12 @@ Add the plugin to your Opencode configuration file
24
24
  }
25
25
  ```
26
26
 
27
+ > [!IMPORTANT]
28
+ > If you're using a paid Gemini Code Assist subscription (Standard/Enterprise),
29
+ > explicitly configure a Google Cloud `projectId`. Free tier accounts should
30
+ > auto-provision a managed project, but you can still set `projectId` to force
31
+ > a specific project.
32
+
27
33
  ## Usage
28
34
 
29
35
  1. **Login**: Run the authentication command in your terminal:
@@ -46,7 +52,10 @@ Once authenticated, Opencode will use your Google account for Gemini requests.
46
52
  ### Google Cloud Project
47
53
 
48
54
  By default, the plugin attempts to provision or find a suitable Google Cloud
49
- project. To force a specific project, set the `projectId` in your configuration:
55
+ project. To force a specific project, set the `projectId` in your configuration
56
+ or via environment variables:
57
+
58
+ **File:** `~/.config/opencode/opencode.json`
50
59
 
51
60
  ```json
52
61
  {
@@ -60,6 +69,9 @@ project. To force a specific project, set the `projectId` in your configuration:
60
69
  }
61
70
  ```
62
71
 
72
+ You can also set `OPENCODE_GEMINI_PROJECT_ID`, `GOOGLE_CLOUD_PROJECT`, or
73
+ `GOOGLE_CLOUD_PROJECT_ID` to supply the project ID via environment variables.
74
+
63
75
  ### Model list
64
76
 
65
77
  Below are example model entries you can add under `provider.google.models` in your
package/index.ts CHANGED
@@ -5,7 +5,7 @@ export {
5
5
 
6
6
  export {
7
7
  authorizeGemini,
8
- exchangeGemini,
8
+ exchangeGeminiWithVerifier,
9
9
  } from "./src/gemini/oauth";
10
10
 
11
11
  export type {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-gemini-auth",
3
3
  "module": "index.ts",
4
- "version": "1.3.8",
4
+ "version": "1.3.9",
5
5
  "author": "jenslys",
6
6
  "repository": "https://github.com/jenslys/opencode-gemini-auth",
7
7
  "files": [
@@ -11,7 +11,7 @@
11
11
  "license": "MIT",
12
12
  "type": "module",
13
13
  "devDependencies": {
14
- "@opencode-ai/plugin": "^1.1.25",
14
+ "@opencode-ai/plugin": "^1.1.48",
15
15
  "@types/bun": "latest"
16
16
  },
17
17
  "peerDependencies": {
@@ -1,4 +1,5 @@
1
1
  import { generatePKCE } from "@openauthjs/openauth/pkce";
2
+ import { randomBytes } from "node:crypto";
2
3
 
3
4
  import {
4
5
  GEMINI_CLIENT_ID,
@@ -6,22 +7,24 @@ import {
6
7
  GEMINI_REDIRECT_URI,
7
8
  GEMINI_SCOPES,
8
9
  } from "../constants";
10
+ import {
11
+ formatDebugBodyPreview,
12
+ isGeminiDebugEnabled,
13
+ logGeminiDebugMessage,
14
+ } from "../plugin/debug";
9
15
 
10
16
  interface PkcePair {
11
17
  challenge: string;
12
18
  verifier: string;
13
19
  }
14
20
 
15
- interface GeminiAuthState {
16
- verifier: string;
17
- }
18
-
19
21
  /**
20
22
  * Result returned to the caller after constructing an OAuth authorization URL.
21
23
  */
22
24
  export interface GeminiAuthorization {
23
25
  url: string;
24
26
  verifier: string;
27
+ state: string;
25
28
  }
26
29
 
27
30
  interface GeminiTokenExchangeSuccess {
@@ -51,34 +54,12 @@ interface GeminiUserInfo {
51
54
  email?: string;
52
55
  }
53
56
 
54
- /**
55
- * Encode an object into a URL-safe base64 string.
56
- */
57
- function encodeState(payload: GeminiAuthState): string {
58
- return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
59
- }
60
-
61
- /**
62
- * Decode an OAuth state parameter back into its structured representation.
63
- */
64
- function decodeState(state: string): GeminiAuthState {
65
- const normalized = state.replace(/-/g, "+").replace(/_/g, "/");
66
- const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), "=");
67
- const json = Buffer.from(padded, "base64").toString("utf8");
68
- const parsed = JSON.parse(json);
69
- if (typeof parsed.verifier !== "string") {
70
- throw new Error("Missing PKCE verifier in state");
71
- }
72
- return {
73
- verifier: parsed.verifier,
74
- };
75
- }
76
-
77
57
  /**
78
58
  * Build the Gemini OAuth authorization URL including PKCE.
79
59
  */
80
60
  export async function authorizeGemini(): Promise<GeminiAuthorization> {
81
61
  const pkce = (await generatePKCE()) as PkcePair;
62
+ const state = randomBytes(32).toString("hex");
82
63
 
83
64
  const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
84
65
  url.searchParams.set("client_id", GEMINI_CLIENT_ID);
@@ -87,7 +68,7 @@ export async function authorizeGemini(): Promise<GeminiAuthorization> {
87
68
  url.searchParams.set("scope", GEMINI_SCOPES.join(" "));
88
69
  url.searchParams.set("code_challenge", pkce.challenge);
89
70
  url.searchParams.set("code_challenge_method", "S256");
90
- url.searchParams.set("state", encodeState({ verifier: pkce.verifier }));
71
+ url.searchParams.set("state", state);
91
72
  url.searchParams.set("access_type", "offline");
92
73
  url.searchParams.set("prompt", "consent");
93
74
  // Add a fragment so any stray terminal glyphs are ignored by the auth server.
@@ -96,28 +77,10 @@ export async function authorizeGemini(): Promise<GeminiAuthorization> {
96
77
  return {
97
78
  url: url.toString(),
98
79
  verifier: pkce.verifier,
80
+ state,
99
81
  };
100
82
  }
101
83
 
102
- /**
103
- * Exchange an authorization code for Gemini CLI access and refresh tokens.
104
- */
105
- export async function exchangeGemini(
106
- code: string,
107
- state: string,
108
- ): Promise<GeminiTokenExchangeResult> {
109
- try {
110
- const { verifier } = decodeState(state);
111
-
112
- return await exchangeGeminiWithVerifierInternal(code, verifier);
113
- } catch (error) {
114
- return {
115
- type: "failed",
116
- error: error instanceof Error ? error.message : "Unknown error",
117
- };
118
- }
119
- }
120
-
121
84
  /**
122
85
  * Exchange an authorization code using a known PKCE verifier.
123
86
  */
@@ -139,6 +102,9 @@ async function exchangeGeminiWithVerifierInternal(
139
102
  code: string,
140
103
  verifier: string,
141
104
  ): Promise<GeminiTokenExchangeResult> {
105
+ if (isGeminiDebugEnabled()) {
106
+ logGeminiDebugMessage("OAuth exchange: POST https://oauth2.googleapis.com/token");
107
+ }
142
108
  const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
143
109
  method: "POST",
144
110
  headers: {
@@ -156,11 +122,28 @@ async function exchangeGeminiWithVerifierInternal(
156
122
 
157
123
  if (!tokenResponse.ok) {
158
124
  const errorText = await tokenResponse.text();
125
+ if (isGeminiDebugEnabled()) {
126
+ logGeminiDebugMessage(
127
+ `OAuth exchange response: ${tokenResponse.status} ${tokenResponse.statusText}`,
128
+ );
129
+ const preview = formatDebugBodyPreview(errorText);
130
+ if (preview) {
131
+ logGeminiDebugMessage(`OAuth exchange error body: ${preview}`);
132
+ }
133
+ }
159
134
  return { type: "failed", error: errorText };
160
135
  }
161
136
 
162
137
  const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
138
+ if (isGeminiDebugEnabled()) {
139
+ logGeminiDebugMessage(
140
+ `OAuth exchange success: expires_in=${tokenPayload.expires_in}s refresh_token=${tokenPayload.refresh_token ? "yes" : "no"}`,
141
+ );
142
+ }
163
143
 
144
+ if (isGeminiDebugEnabled()) {
145
+ logGeminiDebugMessage("OAuth userinfo: GET https://www.googleapis.com/oauth2/v1/userinfo");
146
+ }
164
147
  const userInfoResponse = await fetch(
165
148
  "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
166
149
  {
@@ -169,6 +152,11 @@ async function exchangeGeminiWithVerifierInternal(
169
152
  },
170
153
  },
171
154
  );
155
+ if (isGeminiDebugEnabled()) {
156
+ logGeminiDebugMessage(
157
+ `OAuth userinfo response: ${userInfoResponse.status} ${userInfoResponse.statusText}`,
158
+ );
159
+ }
172
160
 
173
161
  const userInfo = userInfoResponse.ok
174
162
  ? ((await userInfoResponse.json()) as GeminiUserInfo)
@@ -33,6 +33,33 @@ interface GeminiDebugResponseMeta {
33
33
 
34
34
  let requestCounter = 0;
35
35
 
36
+ /**
37
+ * Returns true when Gemini debug logging is enabled.
38
+ */
39
+ export function isGeminiDebugEnabled(): boolean {
40
+ return debugEnabled;
41
+ }
42
+
43
+ /**
44
+ * Writes an arbitrary debug line when debugging is enabled.
45
+ */
46
+ export function logGeminiDebugMessage(message: string): void {
47
+ if (!debugEnabled) {
48
+ return;
49
+ }
50
+ logDebug(`[Gemini Debug] ${message}`);
51
+ }
52
+
53
+ /**
54
+ * Produces a truncated preview of a debug body payload.
55
+ */
56
+ export function formatDebugBodyPreview(text?: string | null): string | undefined {
57
+ if (!text) {
58
+ return undefined;
59
+ }
60
+ return truncateForLog(text);
61
+ }
62
+
36
63
  /**
37
64
  * Begins a debug trace for a Gemini request, logging request metadata when debugging is enabled.
38
65
  */
@@ -82,6 +109,11 @@ export function logGeminiDebugResponse(
82
109
  )}`,
83
110
  );
84
111
 
112
+ const traceId = getHeaderValue(meta.headersOverride ?? response.headers, "x-cloudaicompanion-trace-id");
113
+ if (traceId) {
114
+ logDebug(`[Gemini Debug ${context.id}] Trace ID: ${traceId}`);
115
+ }
116
+
85
117
  if (meta.note) {
86
118
  logDebug(`[Gemini Debug ${context.id}] Note: ${meta.note}`);
87
119
  }
@@ -117,6 +149,34 @@ function maskHeaders(headers?: HeadersInit | Headers): Record<string, string> {
117
149
  return result;
118
150
  }
119
151
 
152
+ /**
153
+ * Reads a header value from a HeadersInit or Headers instance.
154
+ */
155
+ function getHeaderValue(headers: HeadersInit | Headers, key: string): string | undefined {
156
+ const target = key.toLowerCase();
157
+ if (headers instanceof Headers) {
158
+ const value = headers.get(key);
159
+ return value ?? undefined;
160
+ }
161
+
162
+ if (Array.isArray(headers)) {
163
+ for (const [headerKey, headerValue] of headers) {
164
+ if (headerKey.toLowerCase() === target) {
165
+ return headerValue;
166
+ }
167
+ }
168
+ return undefined;
169
+ }
170
+
171
+ const record = headers as Record<string, string | undefined>;
172
+ for (const [headerKey, headerValue] of Object.entries(record)) {
173
+ if (headerKey.toLowerCase() === target) {
174
+ return headerValue ?? undefined;
175
+ }
176
+ }
177
+ return undefined;
178
+ }
179
+
120
180
  /**
121
181
  * Produces a short, type-aware preview of a request/response body for logs.
122
182
  */
@@ -0,0 +1,112 @@
1
+ import { beforeEach, describe, expect, it, mock } from "bun:test";
2
+
3
+ import { resolveProjectContextFromAccessToken } from "./project";
4
+ import type { OAuthAuthDetails } from "./types";
5
+
6
+ const baseAuth: OAuthAuthDetails = {
7
+ type: "oauth",
8
+ refresh: "refresh-token",
9
+ access: "access-token",
10
+ expires: Date.now() + 60_000,
11
+ };
12
+
13
+ function toUrlString(input: RequestInfo): string {
14
+ if (typeof input === "string") {
15
+ return input;
16
+ }
17
+ if (input instanceof URL) {
18
+ return input.toString();
19
+ }
20
+ return (input as Request).url ?? input.toString();
21
+ }
22
+
23
+ describe("resolveProjectContextFromAccessToken", () => {
24
+ beforeEach(() => {
25
+ mock.restore();
26
+ });
27
+
28
+ it("stores managed project id from loadCodeAssist without onboarding", async () => {
29
+ const fetchMock = mock(async (input: RequestInfo) => {
30
+ const url = toUrlString(input);
31
+ if (url.includes(":loadCodeAssist")) {
32
+ return new Response(
33
+ JSON.stringify({
34
+ currentTier: { id: "free-tier" },
35
+ cloudaicompanionProject: "projects/server-project",
36
+ }),
37
+ { status: 200 },
38
+ );
39
+ }
40
+ throw new Error(`Unexpected fetch to ${url}`);
41
+ });
42
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
43
+
44
+ const result = await resolveProjectContextFromAccessToken(
45
+ baseAuth,
46
+ baseAuth.access ?? "",
47
+ );
48
+
49
+ expect(result.effectiveProjectId).toBe("projects/server-project");
50
+ expect(result.auth.refresh).toContain("projects/server-project");
51
+ expect(fetchMock.mock.calls.length).toBe(1);
52
+ });
53
+
54
+ it("onboards free-tier users without sending a project id", async () => {
55
+ let onboardBody: Record<string, unknown> | undefined;
56
+ const fetchMock = mock(async (input: RequestInfo, init?: RequestInit) => {
57
+ const url = toUrlString(input);
58
+ if (url.includes(":loadCodeAssist")) {
59
+ return new Response(
60
+ JSON.stringify({
61
+ allowedTiers: [{ id: "free-tier", isDefault: true }],
62
+ }),
63
+ { status: 200 },
64
+ );
65
+ }
66
+ if (url.includes(":onboardUser")) {
67
+ const rawBody = typeof init?.body === "string" ? init.body : "{}";
68
+ onboardBody = JSON.parse(rawBody) as Record<string, unknown>;
69
+ return new Response(
70
+ JSON.stringify({
71
+ done: true,
72
+ response: { cloudaicompanionProject: { id: "managed-project" } },
73
+ }),
74
+ { status: 200 },
75
+ );
76
+ }
77
+ throw new Error(`Unexpected fetch to ${url}`);
78
+ });
79
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
80
+
81
+ const result = await resolveProjectContextFromAccessToken(
82
+ baseAuth,
83
+ baseAuth.access ?? "",
84
+ );
85
+
86
+ expect(result.effectiveProjectId).toBe("managed-project");
87
+ expect(result.auth.refresh).toContain("managed-project");
88
+ expect(onboardBody?.cloudaicompanionProject).toBeUndefined();
89
+ const metadata = onboardBody?.metadata as Record<string, unknown> | undefined;
90
+ expect(metadata?.duetProject).toBeUndefined();
91
+ });
92
+
93
+ it("throws when a non-free tier requires a project id", async () => {
94
+ const fetchMock = mock(async (input: RequestInfo) => {
95
+ const url = toUrlString(input);
96
+ if (url.includes(":loadCodeAssist")) {
97
+ return new Response(
98
+ JSON.stringify({
99
+ allowedTiers: [{ id: "standard-tier", isDefault: true }],
100
+ }),
101
+ { status: 200 },
102
+ );
103
+ }
104
+ throw new Error(`Unexpected fetch to ${url}`);
105
+ });
106
+ (globalThis as { fetch: typeof fetch }).fetch = fetchMock as unknown as typeof fetch;
107
+
108
+ await expect(
109
+ resolveProjectContextFromAccessToken(baseAuth, baseAuth.access ?? ""),
110
+ ).rejects.toThrow("Google Gemini requires a Google Cloud project");
111
+ });
112
+ });