opencode-gemini-auth 1.3.4 → 1.3.7
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 +1 -1
- package/package.json +2 -2
- package/src/gemini/oauth.ts +69 -45
- package/src/plugin/request-helpers.ts +0 -28
- package/src/plugin/request.ts +107 -33
- package/src/plugin.ts +50 -11
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ Add the plugin to your Opencode configuration file
|
|
|
37
37
|
- A browser window will open for you to approve the access.
|
|
38
38
|
- The plugin spins up a temporary local server to capture the callback.
|
|
39
39
|
- If the local server fails (e.g., port in use or headless environment),
|
|
40
|
-
you can manually
|
|
40
|
+
you can manually paste the callback URL or just the authorization code.
|
|
41
41
|
|
|
42
42
|
Once authenticated, Opencode will use your Google account for Gemini requests.
|
|
43
43
|
|
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.7",
|
|
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": "^0.
|
|
14
|
+
"@opencode-ai/plugin": "^1.0.203",
|
|
15
15
|
"@types/bun": "latest"
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
package/src/gemini/oauth.ts
CHANGED
|
@@ -107,53 +107,24 @@ export async function exchangeGemini(
|
|
|
107
107
|
try {
|
|
108
108
|
const { verifier } = decodeState(state);
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
headers: {
|
|
113
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
114
|
-
},
|
|
115
|
-
body: new URLSearchParams({
|
|
116
|
-
client_id: GEMINI_CLIENT_ID,
|
|
117
|
-
client_secret: GEMINI_CLIENT_SECRET,
|
|
118
|
-
code,
|
|
119
|
-
grant_type: "authorization_code",
|
|
120
|
-
redirect_uri: GEMINI_REDIRECT_URI,
|
|
121
|
-
code_verifier: verifier,
|
|
122
|
-
}),
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
if (!tokenResponse.ok) {
|
|
126
|
-
const errorText = await tokenResponse.text();
|
|
127
|
-
return { type: "failed", error: errorText };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
|
|
131
|
-
|
|
132
|
-
const userInfoResponse = await fetch(
|
|
133
|
-
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
|
134
|
-
{
|
|
135
|
-
headers: {
|
|
136
|
-
Authorization: `Bearer ${tokenPayload.access_token}`,
|
|
137
|
-
},
|
|
138
|
-
},
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
const userInfo = userInfoResponse.ok
|
|
142
|
-
? ((await userInfoResponse.json()) as GeminiUserInfo)
|
|
143
|
-
: {};
|
|
144
|
-
|
|
145
|
-
const refreshToken = tokenPayload.refresh_token;
|
|
146
|
-
if (!refreshToken) {
|
|
147
|
-
return { type: "failed", error: "Missing refresh token in response" };
|
|
148
|
-
}
|
|
149
|
-
|
|
110
|
+
return await exchangeGeminiWithVerifierInternal(code, verifier);
|
|
111
|
+
} catch (error) {
|
|
150
112
|
return {
|
|
151
|
-
type: "
|
|
152
|
-
|
|
153
|
-
access: tokenPayload.access_token,
|
|
154
|
-
expires: Date.now() + tokenPayload.expires_in * 1000,
|
|
155
|
-
email: userInfo.email,
|
|
113
|
+
type: "failed",
|
|
114
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
156
115
|
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Exchange an authorization code using a known PKCE verifier.
|
|
121
|
+
*/
|
|
122
|
+
export async function exchangeGeminiWithVerifier(
|
|
123
|
+
code: string,
|
|
124
|
+
verifier: string,
|
|
125
|
+
): Promise<GeminiTokenExchangeResult> {
|
|
126
|
+
try {
|
|
127
|
+
return await exchangeGeminiWithVerifierInternal(code, verifier);
|
|
157
128
|
} catch (error) {
|
|
158
129
|
return {
|
|
159
130
|
type: "failed",
|
|
@@ -161,3 +132,56 @@ export async function exchangeGemini(
|
|
|
161
132
|
};
|
|
162
133
|
}
|
|
163
134
|
}
|
|
135
|
+
|
|
136
|
+
async function exchangeGeminiWithVerifierInternal(
|
|
137
|
+
code: string,
|
|
138
|
+
verifier: string,
|
|
139
|
+
): Promise<GeminiTokenExchangeResult> {
|
|
140
|
+
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: {
|
|
143
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
144
|
+
},
|
|
145
|
+
body: new URLSearchParams({
|
|
146
|
+
client_id: GEMINI_CLIENT_ID,
|
|
147
|
+
client_secret: GEMINI_CLIENT_SECRET,
|
|
148
|
+
code,
|
|
149
|
+
grant_type: "authorization_code",
|
|
150
|
+
redirect_uri: GEMINI_REDIRECT_URI,
|
|
151
|
+
code_verifier: verifier,
|
|
152
|
+
}),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!tokenResponse.ok) {
|
|
156
|
+
const errorText = await tokenResponse.text();
|
|
157
|
+
return { type: "failed", error: errorText };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const tokenPayload = (await tokenResponse.json()) as GeminiTokenResponse;
|
|
161
|
+
|
|
162
|
+
const userInfoResponse = await fetch(
|
|
163
|
+
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
|
164
|
+
{
|
|
165
|
+
headers: {
|
|
166
|
+
Authorization: `Bearer ${tokenPayload.access_token}`,
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const userInfo = userInfoResponse.ok
|
|
172
|
+
? ((await userInfoResponse.json()) as GeminiUserInfo)
|
|
173
|
+
: {};
|
|
174
|
+
|
|
175
|
+
const refreshToken = tokenPayload.refresh_token;
|
|
176
|
+
if (!refreshToken) {
|
|
177
|
+
return { type: "failed", error: "Missing refresh token in response" };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
type: "success",
|
|
182
|
+
refresh: refreshToken,
|
|
183
|
+
access: tokenPayload.access_token,
|
|
184
|
+
expires: Date.now() + tokenPayload.expires_in * 1000,
|
|
185
|
+
email: userInfo.email,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -120,34 +120,6 @@ export function extractUsageMetadata(body: GeminiApiBody): GeminiUsageMetadata |
|
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
/**
|
|
124
|
-
* Walks SSE lines to find a usage-bearing response chunk.
|
|
125
|
-
*/
|
|
126
|
-
export function extractUsageFromSsePayload(payload: string): GeminiUsageMetadata | null {
|
|
127
|
-
const lines = payload.split("\n");
|
|
128
|
-
for (const line of lines) {
|
|
129
|
-
if (!line.startsWith("data:")) {
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
const jsonText = line.slice(5).trim();
|
|
133
|
-
if (!jsonText) {
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
try {
|
|
137
|
-
const parsed = JSON.parse(jsonText);
|
|
138
|
-
if (parsed && typeof parsed === "object") {
|
|
139
|
-
const usage = extractUsageMetadata({ response: (parsed as Record<string, unknown>).response });
|
|
140
|
-
if (usage) {
|
|
141
|
-
return usage;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
} catch {
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
123
|
/**
|
|
152
124
|
* Enhances 404 errors for Gemini 3 models with a direct preview-access message.
|
|
153
125
|
*/
|
package/src/plugin/request.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { CODE_ASSIST_HEADERS, GEMINI_CODE_ASSIST_ENDPOINT } from "../constants";
|
|
2
2
|
import { logGeminiDebugResponse, type GeminiDebugContext } from "./debug";
|
|
3
3
|
import {
|
|
4
|
-
extractUsageFromSsePayload,
|
|
5
4
|
extractUsageMetadata,
|
|
6
5
|
normalizeThinkingConfig,
|
|
7
6
|
parseGeminiApiBody,
|
|
@@ -20,32 +19,85 @@ const MODEL_FALLBACKS: Record<string, string> = {
|
|
|
20
19
|
* @returns True when the URL targets generativelanguage.googleapis.com.
|
|
21
20
|
*/
|
|
22
21
|
export function isGenerativeLanguageRequest(input: RequestInfo): input is string {
|
|
23
|
-
return
|
|
22
|
+
return toRequestUrlString(input).includes("generativelanguage.googleapis.com");
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
/**
|
|
27
|
-
* Rewrites SSE
|
|
26
|
+
* Rewrites SSE payload lines so downstream consumers see only the inner `response` objects.
|
|
28
27
|
*/
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
function transformStreamingLine(line: string): string {
|
|
29
|
+
if (!line.startsWith("data:")) {
|
|
30
|
+
return line;
|
|
31
|
+
}
|
|
32
|
+
const json = line.slice(5).trim();
|
|
33
|
+
if (!json) {
|
|
34
|
+
return line;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(json) as { response?: unknown };
|
|
38
|
+
if (parsed.response !== undefined) {
|
|
39
|
+
return `data: ${JSON.stringify(parsed.response)}`;
|
|
40
|
+
}
|
|
41
|
+
} catch (_) {}
|
|
42
|
+
return line;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Streams SSE payloads, rewriting data lines on the fly.
|
|
47
|
+
*/
|
|
48
|
+
function transformStreamingPayloadStream(
|
|
49
|
+
stream: ReadableStream<Uint8Array>,
|
|
50
|
+
): ReadableStream<Uint8Array> {
|
|
51
|
+
const decoder = new TextDecoder();
|
|
52
|
+
const encoder = new TextEncoder();
|
|
53
|
+
let buffer = "";
|
|
54
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
55
|
+
|
|
56
|
+
return new ReadableStream<Uint8Array>({
|
|
57
|
+
start(controller) {
|
|
58
|
+
reader = stream.getReader();
|
|
59
|
+
const pump = (): void => {
|
|
60
|
+
reader!
|
|
61
|
+
.read()
|
|
62
|
+
.then(({ done, value }) => {
|
|
63
|
+
if (done) {
|
|
64
|
+
buffer += decoder.decode();
|
|
65
|
+
if (buffer.length > 0) {
|
|
66
|
+
controller.enqueue(encoder.encode(transformStreamingLine(buffer)));
|
|
67
|
+
}
|
|
68
|
+
controller.close();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
buffer += decoder.decode(value, { stream: true });
|
|
73
|
+
|
|
74
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
75
|
+
while (newlineIndex !== -1) {
|
|
76
|
+
const line = buffer.slice(0, newlineIndex);
|
|
77
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
78
|
+
const hasCarriageReturn = line.endsWith("\r");
|
|
79
|
+
const rawLine = hasCarriageReturn ? line.slice(0, -1) : line;
|
|
80
|
+
const transformed = transformStreamingLine(rawLine);
|
|
81
|
+
const suffix = hasCarriageReturn ? "\r\n" : "\n";
|
|
82
|
+
controller.enqueue(encoder.encode(`${transformed}${suffix}`));
|
|
83
|
+
newlineIndex = buffer.indexOf("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
pump();
|
|
87
|
+
})
|
|
88
|
+
.catch((error) => {
|
|
89
|
+
controller.error(error);
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
pump();
|
|
94
|
+
},
|
|
95
|
+
cancel(reason) {
|
|
96
|
+
if (reader) {
|
|
97
|
+
reader.cancel(reason).catch(() => {});
|
|
39
98
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (parsed.response !== undefined) {
|
|
43
|
-
return `data: ${JSON.stringify(parsed.response)}`;
|
|
44
|
-
}
|
|
45
|
-
} catch (_) {}
|
|
46
|
-
return line;
|
|
47
|
-
})
|
|
48
|
-
.join("\n");
|
|
99
|
+
},
|
|
100
|
+
});
|
|
49
101
|
}
|
|
50
102
|
|
|
51
103
|
/**
|
|
@@ -72,7 +124,7 @@ export function prepareGeminiRequest(
|
|
|
72
124
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
73
125
|
headers.delete("x-api-key");
|
|
74
126
|
|
|
75
|
-
const match = input.match(/\/models\/([^:]+):(\w+)/);
|
|
127
|
+
const match = toRequestUrlString(input).match(/\/models\/([^:]+):(\w+)/);
|
|
76
128
|
if (!match) {
|
|
77
129
|
return {
|
|
78
130
|
request: input,
|
|
@@ -136,7 +188,6 @@ export function prepareGeminiRequest(
|
|
|
136
188
|
}
|
|
137
189
|
|
|
138
190
|
delete requestPayload.cached_content;
|
|
139
|
-
delete requestPayload.cachedContent;
|
|
140
191
|
if (requestPayload.extra_body && typeof requestPayload.extra_body === "object") {
|
|
141
192
|
delete (requestPayload.extra_body as Record<string, unknown>).cached_content;
|
|
142
193
|
delete (requestPayload.extra_body as Record<string, unknown>).cachedContent;
|
|
@@ -182,9 +233,23 @@ export function prepareGeminiRequest(
|
|
|
182
233
|
};
|
|
183
234
|
}
|
|
184
235
|
|
|
236
|
+
function toRequestUrlString(value: RequestInfo): string {
|
|
237
|
+
if (typeof value === "string") {
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
if (value instanceof URL) {
|
|
241
|
+
return value.toString();
|
|
242
|
+
}
|
|
243
|
+
const candidate = (value as Request).url;
|
|
244
|
+
if (candidate) {
|
|
245
|
+
return candidate;
|
|
246
|
+
}
|
|
247
|
+
return value.toString();
|
|
248
|
+
}
|
|
249
|
+
|
|
185
250
|
/**
|
|
186
251
|
* Normalizes Gemini responses: applies retry headers, extracts cache usage into headers,
|
|
187
|
-
* rewrites preview errors,
|
|
252
|
+
* rewrites preview errors, rewrites streaming payloads, and logs debug metadata.
|
|
188
253
|
*/
|
|
189
254
|
export async function transformGeminiResponse(
|
|
190
255
|
response: Response,
|
|
@@ -204,8 +269,22 @@ export async function transformGeminiResponse(
|
|
|
204
269
|
}
|
|
205
270
|
|
|
206
271
|
try {
|
|
207
|
-
const text = await response.text();
|
|
208
272
|
const headers = new Headers(response.headers);
|
|
273
|
+
|
|
274
|
+
if (streaming && response.ok && isEventStreamResponse && response.body) {
|
|
275
|
+
logGeminiDebugResponse(debugContext, response, {
|
|
276
|
+
note: "Streaming SSE payload (body omitted)",
|
|
277
|
+
headersOverride: headers,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return new Response(transformStreamingPayloadStream(response.body), {
|
|
281
|
+
status: response.status,
|
|
282
|
+
statusText: response.statusText,
|
|
283
|
+
headers,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const text = await response.text();
|
|
209
288
|
|
|
210
289
|
if (!response.ok && text) {
|
|
211
290
|
try {
|
|
@@ -238,12 +317,11 @@ export async function transformGeminiResponse(
|
|
|
238
317
|
headers,
|
|
239
318
|
};
|
|
240
319
|
|
|
241
|
-
const usageFromSse = streaming && isEventStreamResponse ? extractUsageFromSsePayload(text) : null;
|
|
242
320
|
const parsed: GeminiApiBody | null = !streaming || !isEventStreamResponse ? parseGeminiApiBody(text) : null;
|
|
243
321
|
const patched = parsed ? rewriteGeminiPreviewAccessError(parsed, response.status, requestedModel) : null;
|
|
244
322
|
const effectiveBody = patched ?? parsed ?? undefined;
|
|
245
323
|
|
|
246
|
-
const usage =
|
|
324
|
+
const usage = effectiveBody ? extractUsageMetadata(effectiveBody) : null;
|
|
247
325
|
if (usage?.cachedContentTokenCount !== undefined) {
|
|
248
326
|
headers.set("x-gemini-cached-content-token-count", String(usage.cachedContentTokenCount));
|
|
249
327
|
if (usage.totalTokenCount !== undefined) {
|
|
@@ -259,14 +337,10 @@ export async function transformGeminiResponse(
|
|
|
259
337
|
|
|
260
338
|
logGeminiDebugResponse(debugContext, response, {
|
|
261
339
|
body: text,
|
|
262
|
-
note: streaming ? "Streaming SSE payload" : undefined,
|
|
340
|
+
note: streaming ? "Streaming SSE payload (buffered)" : undefined,
|
|
263
341
|
headersOverride: headers,
|
|
264
342
|
});
|
|
265
343
|
|
|
266
|
-
if (streaming && response.ok && isEventStreamResponse) {
|
|
267
|
-
return new Response(transformStreamingPayload(text), init);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
344
|
if (!parsed) {
|
|
271
345
|
return new Response(text, init);
|
|
272
346
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
|
|
3
3
|
import { GEMINI_PROVIDER_ID, GEMINI_REDIRECT_URI } from "./constants";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
authorizeGemini,
|
|
6
|
+
exchangeGemini,
|
|
7
|
+
exchangeGeminiWithVerifier,
|
|
8
|
+
} from "./gemini/oauth";
|
|
5
9
|
import type { GeminiTokenExchangeResult } from "./gemini/oauth";
|
|
6
10
|
import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
|
|
7
11
|
import { ensureProjectContext } from "./plugin/project";
|
|
@@ -146,16 +150,18 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
146
150
|
} catch (error) {
|
|
147
151
|
if (error instanceof Error) {
|
|
148
152
|
console.log(
|
|
149
|
-
`Warning: Couldn't start the local callback listener (${error.message}). You'll need to paste the callback URL.`,
|
|
153
|
+
`Warning: Couldn't start the local callback listener (${error.message}). You'll need to paste the callback URL or authorization code.`,
|
|
150
154
|
);
|
|
151
155
|
} else {
|
|
152
156
|
console.log(
|
|
153
|
-
"Warning: Couldn't start the local callback listener. You'll need to paste the callback URL.",
|
|
157
|
+
"Warning: Couldn't start the local callback listener. You'll need to paste the callback URL or authorization code.",
|
|
154
158
|
);
|
|
155
159
|
}
|
|
156
160
|
}
|
|
157
161
|
} else {
|
|
158
|
-
console.log(
|
|
162
|
+
console.log(
|
|
163
|
+
"Headless environment detected. You'll need to paste the callback URL or authorization code.",
|
|
164
|
+
);
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
const authorization = await authorizeGemini();
|
|
@@ -201,22 +207,24 @@ export const GeminiCLIOAuthPlugin = async (
|
|
|
201
207
|
return {
|
|
202
208
|
url: authorization.url,
|
|
203
209
|
instructions:
|
|
204
|
-
"Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...)",
|
|
210
|
+
"Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...) or just the authorization code.",
|
|
205
211
|
method: "code",
|
|
206
212
|
callback: async (callbackUrl: string): Promise<GeminiTokenExchangeResult> => {
|
|
207
213
|
try {
|
|
208
|
-
const
|
|
209
|
-
const code = url.searchParams.get("code");
|
|
210
|
-
const state = url.searchParams.get("state");
|
|
214
|
+
const { code, state } = parseOAuthCallbackInput(callbackUrl);
|
|
211
215
|
|
|
212
|
-
if (!code
|
|
216
|
+
if (!code) {
|
|
213
217
|
return {
|
|
214
218
|
type: "failed",
|
|
215
|
-
error: "Missing code
|
|
219
|
+
error: "Missing authorization code in callback input",
|
|
216
220
|
};
|
|
217
221
|
}
|
|
218
222
|
|
|
219
|
-
|
|
223
|
+
if (state) {
|
|
224
|
+
return exchangeGemini(code, state);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return exchangeGeminiWithVerifier(code, authorization.verifier);
|
|
220
228
|
} catch (error) {
|
|
221
229
|
return {
|
|
222
230
|
type: "failed",
|
|
@@ -249,6 +257,37 @@ function toUrlString(value: RequestInfo): string {
|
|
|
249
257
|
return value.toString();
|
|
250
258
|
}
|
|
251
259
|
|
|
260
|
+
function parseOAuthCallbackInput(input: string): { code?: string; state?: string } {
|
|
261
|
+
const trimmed = input.trim();
|
|
262
|
+
if (!trimmed) {
|
|
263
|
+
return {};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
267
|
+
try {
|
|
268
|
+
const url = new URL(trimmed);
|
|
269
|
+
return {
|
|
270
|
+
code: url.searchParams.get("code") || undefined,
|
|
271
|
+
state: url.searchParams.get("state") || undefined,
|
|
272
|
+
};
|
|
273
|
+
} catch {
|
|
274
|
+
return {};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const candidate = trimmed.startsWith("?") ? trimmed.slice(1) : trimmed;
|
|
279
|
+
if (candidate.includes("=")) {
|
|
280
|
+
const params = new URLSearchParams(candidate);
|
|
281
|
+
const code = params.get("code") || undefined;
|
|
282
|
+
const state = params.get("state") || undefined;
|
|
283
|
+
if (code || state) {
|
|
284
|
+
return { code, state };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { code: trimmed };
|
|
289
|
+
}
|
|
290
|
+
|
|
252
291
|
function openBrowserUrl(url: string): void {
|
|
253
292
|
try {
|
|
254
293
|
// Best-effort: don't block auth flow if spawning fails.
|