opencode-gemini-auth-proxy 1.3.10
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 +21 -0
- package/README.md +254 -0
- package/index.ts +14 -0
- package/package.json +23 -0
- package/src/constants.ts +39 -0
- package/src/fetch.ts +11 -0
- package/src/gemini/oauth.ts +178 -0
- package/src/plugin/auth.test.ts +58 -0
- package/src/plugin/auth.ts +46 -0
- package/src/plugin/cache.ts +65 -0
- package/src/plugin/debug.ts +258 -0
- package/src/plugin/project.test.ts +112 -0
- package/src/plugin/project.ts +552 -0
- package/src/plugin/request-helpers.test.ts +84 -0
- package/src/plugin/request-helpers.ts +439 -0
- package/src/plugin/request.test.ts +50 -0
- package/src/plugin/request.ts +483 -0
- package/src/plugin/server.ts +246 -0
- package/src/plugin/token.test.ts +74 -0
- package/src/plugin/token.ts +188 -0
- package/src/plugin/types.ts +76 -0
- package/src/plugin.ts +700 -0
- package/src/shims.d.ts +8 -0
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,254 @@
|
|
|
1
|
+
# Gemini OAuth Plugin for Opencode
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
**Authenticate the Opencode CLI with your Google account.** This plugin enables
|
|
7
|
+
you to use your existing Gemini plan and quotas (including the free tier)
|
|
8
|
+
directly within Opencode, bypassing separate API billing.
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
- [Opencode CLI](https://opencode.ai) installed.
|
|
13
|
+
- A Google account with access to Gemini.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Add the plugin to your Opencode configuration file
|
|
18
|
+
(`~/.config/opencode/opencode.json` or similar):
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"$schema": "https://opencode.ai/config.json",
|
|
23
|
+
"plugin": ["opencode-gemini-auth@latest"]
|
|
24
|
+
}
|
|
25
|
+
```
|
|
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
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
1. **Login**: Run the authentication command in your terminal:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
opencode auth login
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
2. **Select Provider**: Choose **Google** from the list.
|
|
42
|
+
3. **Authenticate**: Select **OAuth with Google (Gemini CLI)**.
|
|
43
|
+
- A browser window will open for you to approve the access.
|
|
44
|
+
- The plugin spins up a temporary local server to capture the callback.
|
|
45
|
+
- If the local server fails (e.g., port in use or headless environment),
|
|
46
|
+
you can manually paste the callback URL or just the authorization code.
|
|
47
|
+
|
|
48
|
+
Once authenticated, Opencode will use your Google account for Gemini requests.
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
### Google Cloud Project
|
|
53
|
+
|
|
54
|
+
By default, the plugin attempts to provision or find a suitable Google Cloud
|
|
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`
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"provider": {
|
|
63
|
+
"google": {
|
|
64
|
+
"options": {
|
|
65
|
+
"projectId": "your-specific-project-id"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
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
|
+
|
|
75
|
+
### Model list
|
|
76
|
+
|
|
77
|
+
Below are example model entries you can add under `provider.google.models` in your
|
|
78
|
+
Opencode config. Each model can include an `options.thinkingConfig` block to
|
|
79
|
+
enable "thinking" features.
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"provider": {
|
|
84
|
+
"google": {
|
|
85
|
+
"models": {
|
|
86
|
+
"gemini-2.5-flash": {
|
|
87
|
+
"options": {
|
|
88
|
+
"thinkingConfig": {
|
|
89
|
+
"thinkingBudget": 8192,
|
|
90
|
+
"includeThoughts": true
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"gemini-2.5-pro": {
|
|
95
|
+
"options": {
|
|
96
|
+
"thinkingConfig": {
|
|
97
|
+
"thinkingBudget": 8192,
|
|
98
|
+
"includeThoughts": true
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"gemini-3-flash-preview": {
|
|
103
|
+
"options": {
|
|
104
|
+
"thinkingConfig": {
|
|
105
|
+
"thinkingLevel": "high",
|
|
106
|
+
"includeThoughts": true
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"gemini-3-pro-preview": {
|
|
111
|
+
"options": {
|
|
112
|
+
"thinkingConfig": {
|
|
113
|
+
"thinkingLevel": "high",
|
|
114
|
+
"includeThoughts": true
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Note: Available model names and previews may change—check Google's documentation or
|
|
125
|
+
the Gemini product page for the current model identifiers.
|
|
126
|
+
|
|
127
|
+
### Thinking Models
|
|
128
|
+
|
|
129
|
+
The plugin supports configuring Gemini "thinking" features per-model via
|
|
130
|
+
`thinkingConfig`. The available fields depend on the model family:
|
|
131
|
+
|
|
132
|
+
- For Gemini 3 models: use `thinkingLevel` with values `"low"` or `"high"`.
|
|
133
|
+
- For Gemini 2.5 models: use `thinkingBudget` (token count).
|
|
134
|
+
- `includeThoughts` (boolean) controls whether the model emits internal thoughts.
|
|
135
|
+
|
|
136
|
+
A combined example showing both model types:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"provider": {
|
|
141
|
+
"google": {
|
|
142
|
+
"models": {
|
|
143
|
+
"gemini-3-pro-preview": {
|
|
144
|
+
"options": {
|
|
145
|
+
"thinkingConfig": {
|
|
146
|
+
"thinkingLevel": "high",
|
|
147
|
+
"includeThoughts": true
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
"gemini-2.5-flash": {
|
|
152
|
+
"options": {
|
|
153
|
+
"thinkingConfig": {
|
|
154
|
+
"thinkingBudget": 8192,
|
|
155
|
+
"includeThoughts": true
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
If you don't set a `thinkingConfig` for a model, the plugin will use default
|
|
166
|
+
behavior for that model.
|
|
167
|
+
|
|
168
|
+
## Troubleshooting
|
|
169
|
+
|
|
170
|
+
### Manual Google Cloud Setup
|
|
171
|
+
|
|
172
|
+
If automatic provisioning fails, you may need to set up the project manually:
|
|
173
|
+
|
|
174
|
+
1. Go to the [Google Cloud Console](https://console.cloud.google.com/).
|
|
175
|
+
2. Create or select a project.
|
|
176
|
+
3. Enable the **Gemini for Google Cloud API**
|
|
177
|
+
(`cloudaicompanion.googleapis.com`).
|
|
178
|
+
4. Configure the `projectId` in your Opencode config as shown above.
|
|
179
|
+
|
|
180
|
+
### Quotas, Plans, and 429 Errors
|
|
181
|
+
|
|
182
|
+
Common causes of `429 RESOURCE_EXHAUSTED` or `QUOTA_EXHAUSTED`:
|
|
183
|
+
|
|
184
|
+
- **No project ID configured**: the plugin uses a managed free-tier project, which has lower quotas.
|
|
185
|
+
- **Model-specific limits**: quotas are tracked per model (e.g., `gemini-3-pro-preview` vs `gemini-3-flash-preview`).
|
|
186
|
+
- **Large prompts**: OAuth/Code Assist does not support cached content, so long system prompts and history can burn quota quickly.
|
|
187
|
+
- **Parallel sessions**: multiple Opencode windows can drain the same bucket.
|
|
188
|
+
|
|
189
|
+
Notes:
|
|
190
|
+
|
|
191
|
+
- **Gemini CLI auto-fallbacks**: the official CLI may fall back to Flash when Pro quotas are exhausted, so it can appear to “work” even if the Pro bucket is depleted.
|
|
192
|
+
- **Paid plans still require a project**: to use paid quotas in Opencode, set `provider.google.options.projectId` (or `OPENCODE_GEMINI_PROJECT_ID`) and re-authenticate.
|
|
193
|
+
|
|
194
|
+
### Debugging
|
|
195
|
+
|
|
196
|
+
To view detailed logs of Gemini requests and responses, set the
|
|
197
|
+
`OPENCODE_GEMINI_DEBUG` environment variable:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
OPENCODE_GEMINI_DEBUG=1 opencode
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
This will generate `gemini-debug-<timestamp>.log` files in your working
|
|
204
|
+
directory containing sanitized request/response details.
|
|
205
|
+
|
|
206
|
+
## Parity Notes
|
|
207
|
+
|
|
208
|
+
This plugin mirrors the official Gemini CLI OAuth flow and Code Assist
|
|
209
|
+
endpoints. In particular, project onboarding and quota retry handling follow
|
|
210
|
+
the same behavior patterns as the Gemini CLI.
|
|
211
|
+
|
|
212
|
+
### References
|
|
213
|
+
|
|
214
|
+
- Gemini CLI repository: https://github.com/google-gemini/gemini-cli
|
|
215
|
+
- Gemini CLI quota documentation: https://developers.google.com/gemini-code-assist/resources/quotas
|
|
216
|
+
|
|
217
|
+
### Updating
|
|
218
|
+
|
|
219
|
+
Opencode does not automatically update plugins. To update to the latest version,
|
|
220
|
+
you must clear the cached plugin:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
# Clear the specific plugin cache
|
|
224
|
+
rm -rf ~/.cache/opencode/node_modules/opencode-gemini-auth
|
|
225
|
+
|
|
226
|
+
# Run Opencode to trigger a fresh install
|
|
227
|
+
opencode
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Development
|
|
231
|
+
|
|
232
|
+
To develop on this plugin locally:
|
|
233
|
+
|
|
234
|
+
1. **Clone**:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
git clone https://github.com/jenslys/opencode-gemini-auth.git
|
|
238
|
+
cd opencode-gemini-auth
|
|
239
|
+
bun install
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
2. **Link**:
|
|
243
|
+
Update your Opencode config to point to your local directory using a
|
|
244
|
+
`file://` URL:
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
{
|
|
248
|
+
"plugin": ["file:///absolute/path/to/opencode-gemini-auth"]
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## License
|
|
253
|
+
|
|
254
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export {
|
|
2
|
+
GeminiCLIOAuthPlugin,
|
|
3
|
+
GoogleOAuthPlugin,
|
|
4
|
+
} from "./src/plugin";
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
authorizeGemini,
|
|
8
|
+
exchangeGeminiWithVerifier,
|
|
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-proxy",
|
|
3
|
+
"module": "index.ts",
|
|
4
|
+
"version": "1.3.10",
|
|
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": "^1.1.48",
|
|
15
|
+
"@types/bun": "latest"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"typescript": "^5.9.3"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@openauthjs/openauth": "^0.4.3"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
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;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Provider identifier shared between the plugin loader and credential store.
|
|
38
|
+
*/
|
|
39
|
+
export const GEMINI_PROVIDER_ID = "google";
|
package/src/fetch.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export default (
|
|
2
|
+
input: RequestInfo,
|
|
3
|
+
init?: RequestInit
|
|
4
|
+
): Promise<Response> =>
|
|
5
|
+
fetch(input, {
|
|
6
|
+
// https://bun.com/docs/guides/http/proxy
|
|
7
|
+
...(process.env.OPENCODE_GEMINI_AUTH_PROXY
|
|
8
|
+
? { proxy: process.env.OPENCODE_GEMINI_AUTH_PROXY }
|
|
9
|
+
: {}),
|
|
10
|
+
...(init ?? {})
|
|
11
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import proxyFetch from "../fetch";
|
|
2
|
+
import { generatePKCE } from "@openauthjs/openauth/pkce";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
GEMINI_CLIENT_ID,
|
|
7
|
+
GEMINI_CLIENT_SECRET,
|
|
8
|
+
GEMINI_REDIRECT_URI,
|
|
9
|
+
GEMINI_SCOPES,
|
|
10
|
+
} from "../constants";
|
|
11
|
+
import {
|
|
12
|
+
formatDebugBodyPreview,
|
|
13
|
+
isGeminiDebugEnabled,
|
|
14
|
+
logGeminiDebugMessage,
|
|
15
|
+
} from "../plugin/debug";
|
|
16
|
+
|
|
17
|
+
interface PkcePair {
|
|
18
|
+
challenge: string;
|
|
19
|
+
verifier: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Result returned to the caller after constructing an OAuth authorization URL.
|
|
24
|
+
*/
|
|
25
|
+
export interface GeminiAuthorization {
|
|
26
|
+
url: string;
|
|
27
|
+
verifier: string;
|
|
28
|
+
state: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface GeminiTokenExchangeSuccess {
|
|
32
|
+
type: "success";
|
|
33
|
+
refresh: string;
|
|
34
|
+
access: string;
|
|
35
|
+
expires: number;
|
|
36
|
+
email?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface GeminiTokenExchangeFailure {
|
|
40
|
+
type: "failed";
|
|
41
|
+
error: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type GeminiTokenExchangeResult =
|
|
45
|
+
| GeminiTokenExchangeSuccess
|
|
46
|
+
| GeminiTokenExchangeFailure;
|
|
47
|
+
|
|
48
|
+
interface GeminiTokenResponse {
|
|
49
|
+
access_token: string;
|
|
50
|
+
expires_in: number;
|
|
51
|
+
refresh_token: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface GeminiUserInfo {
|
|
55
|
+
email?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the Gemini OAuth authorization URL including PKCE.
|
|
60
|
+
*/
|
|
61
|
+
export async function authorizeGemini(): Promise<GeminiAuthorization> {
|
|
62
|
+
const pkce = (await generatePKCE()) as PkcePair;
|
|
63
|
+
const state = randomBytes(32).toString("hex");
|
|
64
|
+
|
|
65
|
+
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
|
|
66
|
+
url.searchParams.set("client_id", GEMINI_CLIENT_ID);
|
|
67
|
+
url.searchParams.set("response_type", "code");
|
|
68
|
+
url.searchParams.set("redirect_uri", GEMINI_REDIRECT_URI);
|
|
69
|
+
url.searchParams.set("scope", GEMINI_SCOPES.join(" "));
|
|
70
|
+
url.searchParams.set("code_challenge", pkce.challenge);
|
|
71
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
72
|
+
url.searchParams.set("state", state);
|
|
73
|
+
url.searchParams.set("access_type", "offline");
|
|
74
|
+
url.searchParams.set("prompt", "consent");
|
|
75
|
+
// Add a fragment so any stray terminal glyphs are ignored by the auth server.
|
|
76
|
+
url.hash = "opencode";
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
url: url.toString(),
|
|
80
|
+
verifier: pkce.verifier,
|
|
81
|
+
state,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Exchange an authorization code using a known PKCE verifier.
|
|
87
|
+
*/
|
|
88
|
+
export async function exchangeGeminiWithVerifier(
|
|
89
|
+
code: string,
|
|
90
|
+
verifier: string,
|
|
91
|
+
): Promise<GeminiTokenExchangeResult> {
|
|
92
|
+
try {
|
|
93
|
+
return await exchangeGeminiWithVerifierInternal(code, verifier);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return {
|
|
96
|
+
type: "failed",
|
|
97
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function exchangeGeminiWithVerifierInternal(
|
|
103
|
+
code: string,
|
|
104
|
+
verifier: string,
|
|
105
|
+
): Promise<GeminiTokenExchangeResult> {
|
|
106
|
+
if (isGeminiDebugEnabled()) {
|
|
107
|
+
logGeminiDebugMessage("OAuth exchange: POST https://oauth2.googleapis.com/token");
|
|
108
|
+
}
|
|
109
|
+
const tokenResponse = await proxyFetch("https://oauth2.googleapis.com/token", {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: {
|
|
112
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
113
|
+
},
|
|
114
|
+
body: new URLSearchParams({
|
|
115
|
+
client_id: GEMINI_CLIENT_ID,
|
|
116
|
+
client_secret: GEMINI_CLIENT_SECRET,
|
|
117
|
+
code,
|
|
118
|
+
grant_type: "authorization_code",
|
|
119
|
+
redirect_uri: GEMINI_REDIRECT_URI,
|
|
120
|
+
code_verifier: verifier,
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!tokenResponse.ok) {
|
|
125
|
+
const errorText = await tokenResponse.text();
|
|
126
|
+
if (isGeminiDebugEnabled()) {
|
|
127
|
+
logGeminiDebugMessage(
|
|
128
|
+
`OAuth exchange response: ${tokenResponse.status} ${tokenResponse.statusText}`,
|
|
129
|
+
);
|
|
130
|
+
const preview = formatDebugBodyPreview(errorText);
|
|
131
|
+
if (preview) {
|
|
132
|
+
logGeminiDebugMessage(`OAuth exchange error body: ${preview}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { type: "failed", error: errorText };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
|
|
139
|
+
if (isGeminiDebugEnabled()) {
|
|
140
|
+
logGeminiDebugMessage(
|
|
141
|
+
`OAuth exchange success: expires_in=${tokenPayload.expires_in}s refresh_token=${tokenPayload.refresh_token ? "yes" : "no"}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isGeminiDebugEnabled()) {
|
|
146
|
+
logGeminiDebugMessage("OAuth userinfo: GET https://www.googleapis.com/oauth2/v1/userinfo");
|
|
147
|
+
}
|
|
148
|
+
const userInfoResponse = await proxyFetch(
|
|
149
|
+
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
|
150
|
+
{
|
|
151
|
+
headers: {
|
|
152
|
+
Authorization: `Bearer ${tokenPayload.access_token}`,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
if (isGeminiDebugEnabled()) {
|
|
157
|
+
logGeminiDebugMessage(
|
|
158
|
+
`OAuth userinfo response: ${userInfoResponse.status} ${userInfoResponse.statusText}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const userInfo = userInfoResponse.ok
|
|
163
|
+
? ((await userInfoResponse.json()) as GeminiUserInfo)
|
|
164
|
+
: {};
|
|
165
|
+
|
|
166
|
+
const refreshToken = tokenPayload.refresh_token;
|
|
167
|
+
if (!refreshToken) {
|
|
168
|
+
return { type: "failed", error: "Missing refresh token in response" };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
type: "success",
|
|
173
|
+
refresh: refreshToken,
|
|
174
|
+
access: tokenPayload.access_token,
|
|
175
|
+
expires: Date.now() + tokenPayload.expires_in * 1000,
|
|
176
|
+
email: userInfo.email,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
accessTokenExpired,
|
|
5
|
+
formatRefreshParts,
|
|
6
|
+
isOAuthAuth,
|
|
7
|
+
parseRefreshParts,
|
|
8
|
+
} from "./auth";
|
|
9
|
+
|
|
10
|
+
describe("auth helpers", () => {
|
|
11
|
+
it("parses refresh parts with project and managed ids", () => {
|
|
12
|
+
const parts = parseRefreshParts("refresh|project-123|managed-456");
|
|
13
|
+
expect(parts.refreshToken).toBe("refresh");
|
|
14
|
+
expect(parts.projectId).toBe("project-123");
|
|
15
|
+
expect(parts.managedProjectId).toBe("managed-456");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("formats refresh parts with optional segments", () => {
|
|
19
|
+
expect(formatRefreshParts({ refreshToken: "refresh" })).toBe("refresh");
|
|
20
|
+
expect(
|
|
21
|
+
formatRefreshParts({
|
|
22
|
+
refreshToken: "refresh",
|
|
23
|
+
projectId: "project-123",
|
|
24
|
+
}),
|
|
25
|
+
).toBe("refresh|project-123|");
|
|
26
|
+
expect(
|
|
27
|
+
formatRefreshParts({
|
|
28
|
+
refreshToken: "refresh",
|
|
29
|
+
managedProjectId: "managed-456",
|
|
30
|
+
}),
|
|
31
|
+
).toBe("refresh||managed-456");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("detects OAuth auth payloads", () => {
|
|
35
|
+
expect(isOAuthAuth({ type: "oauth", refresh: "refresh" })).toBe(true);
|
|
36
|
+
expect(isOAuthAuth({ type: "api", refresh: "refresh" } as never)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("treats tokens near expiry as expired", () => {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
expect(
|
|
42
|
+
accessTokenExpired({
|
|
43
|
+
type: "oauth",
|
|
44
|
+
refresh: "refresh",
|
|
45
|
+
access: "access",
|
|
46
|
+
expires: now + 59_000,
|
|
47
|
+
}),
|
|
48
|
+
).toBe(true);
|
|
49
|
+
expect(
|
|
50
|
+
accessTokenExpired({
|
|
51
|
+
type: "oauth",
|
|
52
|
+
refresh: "refresh",
|
|
53
|
+
access: "access",
|
|
54
|
+
expires: now + 61_000,
|
|
55
|
+
}),
|
|
56
|
+
).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AuthDetails, OAuthAuthDetails, RefreshParts } from "./types";
|
|
2
|
+
|
|
3
|
+
const ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;
|
|
4
|
+
|
|
5
|
+
export function isOAuthAuth(auth: AuthDetails): auth is OAuthAuthDetails {
|
|
6
|
+
return auth.type === "oauth";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Splits a packed refresh string into its constituent refresh token and project IDs.
|
|
11
|
+
*/
|
|
12
|
+
export function parseRefreshParts(refresh: string): RefreshParts {
|
|
13
|
+
const [refreshToken = "", projectId = "", managedProjectId = ""] = (refresh ?? "").split("|");
|
|
14
|
+
return {
|
|
15
|
+
refreshToken,
|
|
16
|
+
projectId: projectId || undefined,
|
|
17
|
+
managedProjectId: managedProjectId || undefined,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Serializes refresh token parts into the stored string format.
|
|
23
|
+
*/
|
|
24
|
+
export function formatRefreshParts(parts: RefreshParts): string {
|
|
25
|
+
if (!parts.refreshToken) {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!parts.projectId && !parts.managedProjectId) {
|
|
30
|
+
return parts.refreshToken;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const projectSegment = parts.projectId ?? "";
|
|
34
|
+
const managedSegment = parts.managedProjectId ?? "";
|
|
35
|
+
return `${parts.refreshToken}|${projectSegment}|${managedSegment}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Determines whether an access token is expired or missing, with buffer for clock skew.
|
|
40
|
+
*/
|
|
41
|
+
export function accessTokenExpired(auth: OAuthAuthDetails): boolean {
|
|
42
|
+
if (!auth.access || typeof auth.expires !== "number") {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return auth.expires <= Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER_MS;
|
|
46
|
+
}
|