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 +13 -1
- package/index.ts +1 -1
- package/package.json +2 -2
- package/src/gemini/oauth.ts +35 -47
- package/src/plugin/debug.ts +60 -0
- package/src/plugin/project.test.ts +112 -0
- package/src/plugin/project.ts +314 -121
- package/src/plugin/request-helpers.ts +249 -0
- package/src/plugin/request.ts +14 -30
- package/src/plugin/token.ts +32 -7
- package/src/plugin.ts +85 -12
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
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.
|
|
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.
|
|
14
|
+
"@opencode-ai/plugin": "^1.1.48",
|
|
15
15
|
"@types/bun": "latest"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
package/src/gemini/oauth.ts
CHANGED
|
@@ -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",
|
|
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)
|
package/src/plugin/debug.ts
CHANGED
|
@@ -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
|
+
});
|