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
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
|
|
3
|
+
import { GEMINI_REDIRECT_URI } from "../constants";
|
|
4
|
+
|
|
5
|
+
interface OAuthListenerOptions {
|
|
6
|
+
/**
|
|
7
|
+
* How long to wait for the OAuth redirect before timing out (in milliseconds).
|
|
8
|
+
*/
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface OAuthListener {
|
|
13
|
+
/**
|
|
14
|
+
* Resolves with the callback URL once Google redirects back to the local server.
|
|
15
|
+
*/
|
|
16
|
+
waitForCallback(): Promise<URL>;
|
|
17
|
+
/**
|
|
18
|
+
* Cleanly stop listening for callbacks.
|
|
19
|
+
*/
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const redirectUri = new URL(GEMINI_REDIRECT_URI);
|
|
24
|
+
const callbackPath = redirectUri.pathname || "/";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Starts a lightweight HTTP server that listens for the Gemini OAuth redirect
|
|
28
|
+
* and resolves with the captured callback URL.
|
|
29
|
+
*/
|
|
30
|
+
export async function startOAuthListener(
|
|
31
|
+
{ timeoutMs = 5 * 60 * 1000 }: OAuthListenerOptions = {},
|
|
32
|
+
): Promise<OAuthListener> {
|
|
33
|
+
const port = redirectUri.port
|
|
34
|
+
? Number.parseInt(redirectUri.port, 10)
|
|
35
|
+
: redirectUri.protocol === "https:"
|
|
36
|
+
? 443
|
|
37
|
+
: 80;
|
|
38
|
+
const origin = `${redirectUri.protocol}//${redirectUri.host}`;
|
|
39
|
+
|
|
40
|
+
let settled = false;
|
|
41
|
+
let resolveCallback: (url: URL) => void;
|
|
42
|
+
let rejectCallback: (error: Error) => void;
|
|
43
|
+
const callbackPromise = new Promise<URL>((resolve, reject) => {
|
|
44
|
+
resolveCallback = (url: URL) => {
|
|
45
|
+
if (settled) return;
|
|
46
|
+
settled = true;
|
|
47
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
48
|
+
resolve(url);
|
|
49
|
+
};
|
|
50
|
+
rejectCallback = (error: Error) => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
54
|
+
reject(error);
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const successResponse = `<!DOCTYPE html>
|
|
59
|
+
<html lang="en">
|
|
60
|
+
<head>
|
|
61
|
+
<meta charset="utf-8" />
|
|
62
|
+
<title>Opencode Gemini OAuth</title>
|
|
63
|
+
<style>
|
|
64
|
+
:root { color-scheme: light dark; }
|
|
65
|
+
body {
|
|
66
|
+
margin: 0;
|
|
67
|
+
min-height: 100vh;
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
justify-content: center;
|
|
71
|
+
font-family: "Roboto", "Google Sans", arial, sans-serif;
|
|
72
|
+
background: #f1f3f4;
|
|
73
|
+
color: #202124;
|
|
74
|
+
}
|
|
75
|
+
main {
|
|
76
|
+
width: min(448px, calc(100% - 3rem));
|
|
77
|
+
background: #ffffff;
|
|
78
|
+
border-radius: 28px;
|
|
79
|
+
padding: 2.5rem 2.75rem;
|
|
80
|
+
box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3), 0 2px 6px rgba(60, 64, 67, 0.15);
|
|
81
|
+
}
|
|
82
|
+
header {
|
|
83
|
+
display: flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
gap: 0.75rem;
|
|
86
|
+
margin-bottom: 1.5rem;
|
|
87
|
+
}
|
|
88
|
+
.logo {
|
|
89
|
+
width: 40px;
|
|
90
|
+
height: 40px;
|
|
91
|
+
display: inline-flex;
|
|
92
|
+
}
|
|
93
|
+
.logo svg {
|
|
94
|
+
width: 100%;
|
|
95
|
+
height: 100%;
|
|
96
|
+
}
|
|
97
|
+
.brand {
|
|
98
|
+
font-size: 1.1rem;
|
|
99
|
+
font-weight: 500;
|
|
100
|
+
letter-spacing: 0.01em;
|
|
101
|
+
}
|
|
102
|
+
h1 {
|
|
103
|
+
margin: 0 0 0.75rem;
|
|
104
|
+
font-size: 1.75rem;
|
|
105
|
+
font-weight: 500;
|
|
106
|
+
letter-spacing: -0.01em;
|
|
107
|
+
}
|
|
108
|
+
p {
|
|
109
|
+
margin: 0 0 1.75rem;
|
|
110
|
+
font-size: 1.05rem;
|
|
111
|
+
line-height: 1.6;
|
|
112
|
+
color: #3c4043;
|
|
113
|
+
}
|
|
114
|
+
.note {
|
|
115
|
+
margin: 1.5rem 0 0;
|
|
116
|
+
font-size: 0.92rem;
|
|
117
|
+
color: #5f6368;
|
|
118
|
+
}
|
|
119
|
+
.action {
|
|
120
|
+
display: inline-flex;
|
|
121
|
+
align-items: center;
|
|
122
|
+
justify-content: center;
|
|
123
|
+
padding: 0.65rem 1.85rem;
|
|
124
|
+
border-radius: 999px;
|
|
125
|
+
background: #1a73e8;
|
|
126
|
+
color: #ffffff;
|
|
127
|
+
font-weight: 500;
|
|
128
|
+
font-size: 0.95rem;
|
|
129
|
+
letter-spacing: 0.02em;
|
|
130
|
+
text-decoration: none;
|
|
131
|
+
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
|
132
|
+
}
|
|
133
|
+
.action:hover {
|
|
134
|
+
transform: translateY(-1px);
|
|
135
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(60, 64, 67, 0.15);
|
|
136
|
+
}
|
|
137
|
+
.action:focus-visible {
|
|
138
|
+
outline: none;
|
|
139
|
+
box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.3);
|
|
140
|
+
}
|
|
141
|
+
@media (prefers-color-scheme: dark) {
|
|
142
|
+
body {
|
|
143
|
+
background: #131314;
|
|
144
|
+
color: #e8eaed;
|
|
145
|
+
}
|
|
146
|
+
main {
|
|
147
|
+
background: #202124;
|
|
148
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.4);
|
|
149
|
+
}
|
|
150
|
+
p {
|
|
151
|
+
color: #e8eaed;
|
|
152
|
+
}
|
|
153
|
+
.note {
|
|
154
|
+
color: #bdc1c6;
|
|
155
|
+
}
|
|
156
|
+
.action {
|
|
157
|
+
background: #8ab4f8;
|
|
158
|
+
color: #202124;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
</style>
|
|
162
|
+
</head>
|
|
163
|
+
<body>
|
|
164
|
+
<main>
|
|
165
|
+
<header>
|
|
166
|
+
<span class="logo" aria-hidden="true">
|
|
167
|
+
<svg viewBox="0 0 46 46" xmlns="http://www.w3.org/2000/svg" role="img">
|
|
168
|
+
<title>Gemini linked to Opencode</title>
|
|
169
|
+
<path fill="#4285F4" d="M43.6 23.5c0-1.5-.1-3-.4-4.4H23v8.3h11.6c-.5 2.8-2 5.1-4.2 6.7v5.5h6.8c4-3.7 6.4-9.1 6.4-16.1z"/>
|
|
170
|
+
<path fill="#34A853" d="M23 45c5.8 0 10.6-1.9 14.1-5.2l-6.8-5.5c-1.9 1.3-4.3 2-7.3 2-5.6 0-10.4-3.7-12.1-8.7H3.8v5.6C7.3 39.9 14.6 45 23 45z"/>
|
|
171
|
+
<path fill="#FBBC04" d="M10.9 28.6c-.5-1.3-.8-2.7-.8-4.1 0-1.5.3-2.8.8-4.1v-5.6H3.8C2.3 17.7 1.5 20.2 1.5 24s.8 6.3 2.3 9.2l6.9-5.6z"/>
|
|
172
|
+
<path fill="#EA4335" d="M23 9.5c3.2 0 6 .9 8.3 2.7l6.2-6.2C33.6 2.2 28.8 0 23 0 14.6 0 7.3 5.1 3.8 12.4l7.1 5.6c1.7-5 6.5-8.5 12.1-8.5z"/>
|
|
173
|
+
</svg>
|
|
174
|
+
</span>
|
|
175
|
+
<span class="brand">Gemini linked to Opencode</span>
|
|
176
|
+
</header>
|
|
177
|
+
<h1>You're connected to Opencode</h1>
|
|
178
|
+
<p>Your Google account is now linked to Opencode. You can close this window and continue in the CLI.</p>
|
|
179
|
+
<a class="action" href="javascript:window.close()">Close window</a>
|
|
180
|
+
<p class="note">Need to reconnect later? Re-run the authentication command in Opencode.</p>
|
|
181
|
+
</main>
|
|
182
|
+
</body>
|
|
183
|
+
</html>`;
|
|
184
|
+
|
|
185
|
+
const timeoutHandle = setTimeout(() => {
|
|
186
|
+
rejectCallback(new Error("Timed out waiting for OAuth callback"));
|
|
187
|
+
}, timeoutMs);
|
|
188
|
+
timeoutHandle.unref?.();
|
|
189
|
+
|
|
190
|
+
const server = createServer((request, response) => {
|
|
191
|
+
if (!request.url) {
|
|
192
|
+
response.writeHead(400, { "Content-Type": "text/plain" });
|
|
193
|
+
response.end("Invalid request");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const url = new URL(request.url, origin);
|
|
198
|
+
if (url.pathname !== callbackPath) {
|
|
199
|
+
response.writeHead(404, { "Content-Type": "text/plain" });
|
|
200
|
+
response.end("Not found");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
205
|
+
response.end(successResponse);
|
|
206
|
+
|
|
207
|
+
resolveCallback(url);
|
|
208
|
+
|
|
209
|
+
setImmediate(() => {
|
|
210
|
+
server.close();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await new Promise<void>((resolve, reject) => {
|
|
215
|
+
const handleError = (error: Error) => {
|
|
216
|
+
server.off("error", handleError);
|
|
217
|
+
reject(error);
|
|
218
|
+
};
|
|
219
|
+
server.once("error", handleError);
|
|
220
|
+
server.listen(port, "127.0.0.1", () => {
|
|
221
|
+
server.off("error", handleError);
|
|
222
|
+
resolve();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
server.on("error", (error) => {
|
|
227
|
+
rejectCallback(error instanceof Error ? error : new Error(String(error)));
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
waitForCallback: () => callbackPromise,
|
|
232
|
+
close: () =>
|
|
233
|
+
new Promise<void>((resolve, reject) => {
|
|
234
|
+
server.close((error) => {
|
|
235
|
+
if (error && (error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") {
|
|
236
|
+
reject(error);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (!settled) {
|
|
240
|
+
rejectCallback(new Error("OAuth listener closed before callback"));
|
|
241
|
+
}
|
|
242
|
+
resolve();
|
|
243
|
+
});
|
|
244
|
+
}),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GEMINI_CLIENT_ID,
|
|
3
|
+
GEMINI_CLIENT_SECRET,
|
|
4
|
+
GEMINI_PROVIDER_ID,
|
|
5
|
+
} from "../constants";
|
|
6
|
+
import { formatRefreshParts, parseRefreshParts } from "./auth";
|
|
7
|
+
import { clearCachedAuth, storeCachedAuth } from "./cache";
|
|
8
|
+
import {
|
|
9
|
+
formatDebugBodyPreview,
|
|
10
|
+
isGeminiDebugEnabled,
|
|
11
|
+
logGeminiDebugMessage,
|
|
12
|
+
} from "./debug";
|
|
13
|
+
import { invalidateProjectContextCache } from "./project";
|
|
14
|
+
import type { OAuthAuthDetails, PluginClient, RefreshParts } from "./types";
|
|
15
|
+
import proxyFetch from '../fetch';
|
|
16
|
+
|
|
17
|
+
interface OAuthErrorPayload {
|
|
18
|
+
error?:
|
|
19
|
+
| string
|
|
20
|
+
| {
|
|
21
|
+
code?: string;
|
|
22
|
+
status?: string;
|
|
23
|
+
message?: string;
|
|
24
|
+
};
|
|
25
|
+
error_description?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parses OAuth error payloads returned by Google token endpoints, tolerating varied shapes.
|
|
30
|
+
*/
|
|
31
|
+
function parseOAuthErrorPayload(text: string | undefined): { code?: string; description?: string } {
|
|
32
|
+
if (!text) {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const payload = JSON.parse(text) as OAuthErrorPayload;
|
|
38
|
+
if (!payload || typeof payload !== "object") {
|
|
39
|
+
return { description: text };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let code: string | undefined;
|
|
43
|
+
if (typeof payload.error === "string") {
|
|
44
|
+
code = payload.error;
|
|
45
|
+
} else if (payload.error && typeof payload.error === "object") {
|
|
46
|
+
code = payload.error.status ?? payload.error.code;
|
|
47
|
+
if (!payload.error_description && payload.error.message) {
|
|
48
|
+
return { code, description: payload.error.message };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const description = payload.error_description;
|
|
53
|
+
if (description) {
|
|
54
|
+
return { code, description };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (payload.error && typeof payload.error === "object" && payload.error.message) {
|
|
58
|
+
return { code, description: payload.error.message };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { code };
|
|
62
|
+
} catch {
|
|
63
|
+
return { description: text };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Refreshes a Gemini OAuth access token, updates persisted credentials, and handles revocation.
|
|
69
|
+
*/
|
|
70
|
+
export async function refreshAccessToken(
|
|
71
|
+
auth: OAuthAuthDetails,
|
|
72
|
+
client: PluginClient,
|
|
73
|
+
): Promise<OAuthAuthDetails | undefined> {
|
|
74
|
+
const parts = parseRefreshParts(auth.refresh);
|
|
75
|
+
if (!parts.refreshToken) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
if (isGeminiDebugEnabled()) {
|
|
81
|
+
logGeminiDebugMessage("OAuth refresh: POST https://oauth2.googleapis.com/token");
|
|
82
|
+
}
|
|
83
|
+
const response = await proxyFetch("https://oauth2.googleapis.com/token", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
87
|
+
},
|
|
88
|
+
body: new URLSearchParams({
|
|
89
|
+
grant_type: "refresh_token",
|
|
90
|
+
refresh_token: parts.refreshToken,
|
|
91
|
+
client_id: GEMINI_CLIENT_ID,
|
|
92
|
+
client_secret: GEMINI_CLIENT_SECRET,
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
let errorText: string | undefined;
|
|
98
|
+
try {
|
|
99
|
+
errorText = await response.text();
|
|
100
|
+
} catch {
|
|
101
|
+
errorText = undefined;
|
|
102
|
+
}
|
|
103
|
+
if (isGeminiDebugEnabled()) {
|
|
104
|
+
logGeminiDebugMessage(
|
|
105
|
+
`OAuth refresh response: ${response.status} ${response.statusText}`,
|
|
106
|
+
);
|
|
107
|
+
const preview = formatDebugBodyPreview(errorText);
|
|
108
|
+
if (preview) {
|
|
109
|
+
logGeminiDebugMessage(`OAuth refresh error body: ${preview}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const { code, description } = parseOAuthErrorPayload(errorText);
|
|
114
|
+
const details = [code, description ?? errorText].filter(Boolean).join(": ");
|
|
115
|
+
const baseMessage = `Gemini token refresh failed (${response.status} ${response.statusText})`;
|
|
116
|
+
console.warn(`[Gemini OAuth] ${details ? `${baseMessage} - ${details}` : baseMessage}`);
|
|
117
|
+
|
|
118
|
+
if (code === "invalid_grant") {
|
|
119
|
+
console.warn(
|
|
120
|
+
"[Gemini OAuth] Google revoked the stored refresh token. Run `opencode auth login` and reauthenticate the Google provider.",
|
|
121
|
+
);
|
|
122
|
+
invalidateProjectContextCache(auth.refresh);
|
|
123
|
+
try {
|
|
124
|
+
const clearedAuth: OAuthAuthDetails = {
|
|
125
|
+
type: "oauth",
|
|
126
|
+
refresh: formatRefreshParts({
|
|
127
|
+
refreshToken: "",
|
|
128
|
+
projectId: parts.projectId,
|
|
129
|
+
managedProjectId: parts.managedProjectId,
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
await client.auth.set({
|
|
133
|
+
path: { id: GEMINI_PROVIDER_ID },
|
|
134
|
+
body: clearedAuth,
|
|
135
|
+
});
|
|
136
|
+
} catch (storeError) {
|
|
137
|
+
console.error("Failed to clear stored Gemini OAuth credentials:", storeError);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const payload = (await response.json()) as {
|
|
145
|
+
access_token: string;
|
|
146
|
+
expires_in: number;
|
|
147
|
+
refresh_token?: string;
|
|
148
|
+
};
|
|
149
|
+
if (isGeminiDebugEnabled()) {
|
|
150
|
+
const rotated = payload.refresh_token && payload.refresh_token !== parts.refreshToken;
|
|
151
|
+
logGeminiDebugMessage(
|
|
152
|
+
`OAuth refresh success: expires_in=${payload.expires_in}s refresh_rotated=${rotated ? "yes" : "no"}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const refreshedParts: RefreshParts = {
|
|
157
|
+
refreshToken: payload.refresh_token ?? parts.refreshToken,
|
|
158
|
+
projectId: parts.projectId,
|
|
159
|
+
managedProjectId: parts.managedProjectId,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const updatedAuth: OAuthAuthDetails = {
|
|
163
|
+
...auth,
|
|
164
|
+
access: payload.access_token,
|
|
165
|
+
expires: Date.now() + payload.expires_in * 1000,
|
|
166
|
+
refresh: formatRefreshParts(refreshedParts),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
storeCachedAuth(updatedAuth);
|
|
170
|
+
invalidateProjectContextCache(auth.refresh);
|
|
171
|
+
|
|
172
|
+
if (refreshedParts.refreshToken !== parts.refreshToken) {
|
|
173
|
+
try {
|
|
174
|
+
await client.auth.set({
|
|
175
|
+
path: { id: GEMINI_PROVIDER_ID },
|
|
176
|
+
body: updatedAuth,
|
|
177
|
+
});
|
|
178
|
+
} catch (storeError) {
|
|
179
|
+
console.error("Failed to persist refreshed Gemini OAuth credentials:", storeError);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return updatedAuth;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.error("Failed to refresh Gemini access token due to an unexpected error:", error);
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
options?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LoaderResult {
|
|
33
|
+
apiKey: string;
|
|
34
|
+
fetch(input: RequestInfo, init?: RequestInit): Promise<Response>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AuthMethod {
|
|
38
|
+
provider?: string;
|
|
39
|
+
label: string;
|
|
40
|
+
type: "oauth" | "api";
|
|
41
|
+
authorize?: () => Promise<{
|
|
42
|
+
url: string;
|
|
43
|
+
instructions: string;
|
|
44
|
+
method: string;
|
|
45
|
+
callback: (() => Promise<GeminiTokenExchangeResult>) | ((callbackUrl: string) => Promise<GeminiTokenExchangeResult>);
|
|
46
|
+
}>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PluginClient {
|
|
50
|
+
auth: {
|
|
51
|
+
set(input: { path: { id: string }; body: OAuthAuthDetails }): Promise<void>;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PluginContext {
|
|
56
|
+
client: PluginClient;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PluginResult {
|
|
60
|
+
auth: {
|
|
61
|
+
provider: string;
|
|
62
|
+
loader: (getAuth: GetAuth, provider: Provider) => Promise<LoaderResult | null>;
|
|
63
|
+
methods: AuthMethod[];
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RefreshParts {
|
|
68
|
+
refreshToken: string;
|
|
69
|
+
projectId?: string;
|
|
70
|
+
managedProjectId?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ProjectContextResult {
|
|
74
|
+
auth: OAuthAuthDetails;
|
|
75
|
+
effectiveProjectId: string;
|
|
76
|
+
}
|