@squidlerio/squidler-mcp 1.0.0
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 +99 -0
- package/dist/cli-hhgt3239.js +370 -0
- package/dist/cli-hreaeftx.js +374 -0
- package/dist/cli-w466m345.js +377 -0
- package/dist/cli.js +46 -0
- package/dist/mcp-proxy-server.js +532 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @squidlerio/mcp
|
|
2
|
+
|
|
3
|
+
MCP proxy that sits between an AI client (Claude, Cursor, etc.) and the remote Squidler MCP server. It forwards all tools, resources, and prompts transparently, while adding local Chrome session management for testing localhost URLs.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
AI Client (stdio) ←→ MCP Proxy ←→ Remote Squidler MCP (HTTP)
|
|
9
|
+
↕
|
|
10
|
+
Local Chrome ←→ CDP Proxy (WebSocket)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The proxy intercepts `test_case_run` calls — when local Chrome mode is enabled, it automatically creates a CDP session and routes the test through your local browser instead of the cloud worker's Chrome.
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
Run directly with npx — no install or API key needed:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx @squidlerio/mcp
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
On first use, a browser window opens for you to sign in to Squidler. Your session is saved locally so you only need to do this once.
|
|
24
|
+
|
|
25
|
+
### Claude Code
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
claude mcp add squidler -- npx -y @squidlerio/mcp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Cursor / Other MCP Clients
|
|
32
|
+
|
|
33
|
+
Add to your MCP client configuration:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"squidler": {
|
|
39
|
+
"command": "npx",
|
|
40
|
+
"args": ["-y", "@squidlerio/mcp"]
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## CLI Commands
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Sign in to Squidler (happens automatically on first use)
|
|
50
|
+
squidler-mcp login
|
|
51
|
+
|
|
52
|
+
# Sign out and clear saved session
|
|
53
|
+
squidler-mcp logout
|
|
54
|
+
|
|
55
|
+
# Download Chrome headless shell for local testing
|
|
56
|
+
squidler-mcp download-chrome
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Local Session Tools
|
|
60
|
+
|
|
61
|
+
These tools are added by the proxy (not available on the remote server):
|
|
62
|
+
|
|
63
|
+
- **`local_session_start`** — Enable local Chrome mode. Accepts `headless` (boolean, default: true). Chrome is launched on the first `test_case_run`.
|
|
64
|
+
- **`local_session_stop`** — Disable local Chrome mode and stop any active session.
|
|
65
|
+
- **`local_session_status`** — Check if local Chrome mode is enabled and if a session is active.
|
|
66
|
+
|
|
67
|
+
When local Chrome mode is enabled, `test_case_run` automatically creates/recycles a CDP session and routes through your local Chrome. Back-to-back tests get a fresh Chrome instance each time.
|
|
68
|
+
|
|
69
|
+
## Advanced: API Key Override
|
|
70
|
+
|
|
71
|
+
If you prefer to use an API key instead of OAuth login, set the `SQUIDLER_API_KEY` environment variable:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"mcpServers": {
|
|
76
|
+
"squidler": {
|
|
77
|
+
"command": "npx",
|
|
78
|
+
"args": ["-y", "@squidlerio/mcp"],
|
|
79
|
+
"env": {
|
|
80
|
+
"SQUIDLER_API_KEY": "your-api-key"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Variable | Required | Default | Description |
|
|
88
|
+
|---|---|---|---|
|
|
89
|
+
| `SQUIDLER_API_KEY` | No | — | API key override (skips OAuth login) |
|
|
90
|
+
| `SQUIDLER_API_URL` | No | `https://mcp.squidler.io` | Remote MCP server URL |
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
bun install
|
|
96
|
+
bun run start # Run CLI
|
|
97
|
+
bun run mcp-proxy # Run MCP proxy
|
|
98
|
+
bun run build # Build for npm publishing
|
|
99
|
+
```
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/chrome/download.ts
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import * as os from "os";
|
|
8
|
+
import * as https from "https";
|
|
9
|
+
import * as unzipper from "unzipper";
|
|
10
|
+
var DEFAULT_CHROME_VERSION = "136.0.7103.59";
|
|
11
|
+
function getPlatform() {
|
|
12
|
+
const platform2 = os.platform();
|
|
13
|
+
const arch2 = os.arch();
|
|
14
|
+
if (platform2 === "darwin") {
|
|
15
|
+
return arch2 === "arm64" ? "mac-arm64" : "mac-x64";
|
|
16
|
+
} else if (platform2 === "linux") {
|
|
17
|
+
return "linux64";
|
|
18
|
+
} else if (platform2 === "win32") {
|
|
19
|
+
return "win64";
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`Unsupported platform: ${platform2} ${arch2}`);
|
|
22
|
+
}
|
|
23
|
+
function getChromePath(extractDir, platform2) {
|
|
24
|
+
const baseName = "chrome-headless-shell-" + platform2;
|
|
25
|
+
switch (platform2) {
|
|
26
|
+
case "mac-arm64":
|
|
27
|
+
case "mac-x64":
|
|
28
|
+
return path.join(extractDir, baseName, "chrome-headless-shell");
|
|
29
|
+
case "linux64":
|
|
30
|
+
return path.join(extractDir, baseName, "chrome-headless-shell");
|
|
31
|
+
case "win64":
|
|
32
|
+
return path.join(extractDir, baseName, "chrome-headless-shell.exe");
|
|
33
|
+
default:
|
|
34
|
+
throw new Error(`Unknown platform: ${platform2}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function downloadFile(url, destPath) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const file = fs.createWriteStream(destPath);
|
|
40
|
+
const request = https.get(url, (response) => {
|
|
41
|
+
if (response.statusCode === 301 || response.statusCode === 302) {
|
|
42
|
+
const redirectUrl = response.headers.location;
|
|
43
|
+
if (redirectUrl) {
|
|
44
|
+
file.close();
|
|
45
|
+
fs.unlinkSync(destPath);
|
|
46
|
+
downloadFile(redirectUrl, destPath).then(resolve).catch(reject);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (response.statusCode !== 200) {
|
|
51
|
+
reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const totalSize = parseInt(response.headers["content-length"] || "0", 10);
|
|
55
|
+
let downloadedSize = 0;
|
|
56
|
+
response.on("data", (chunk) => {
|
|
57
|
+
downloadedSize += chunk.length;
|
|
58
|
+
if (totalSize > 0) {
|
|
59
|
+
const percent = Math.round(downloadedSize / totalSize * 100);
|
|
60
|
+
process.stderr.write(`\rDownloading Chrome... ${percent}%`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
response.pipe(file);
|
|
64
|
+
file.on("finish", () => {
|
|
65
|
+
file.close();
|
|
66
|
+
console.error(`
|
|
67
|
+
Download complete.`);
|
|
68
|
+
resolve();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
request.on("error", (err) => {
|
|
72
|
+
fs.unlink(destPath, () => {});
|
|
73
|
+
reject(err);
|
|
74
|
+
});
|
|
75
|
+
file.on("error", (err) => {
|
|
76
|
+
fs.unlink(destPath, () => {});
|
|
77
|
+
reject(err);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async function extractZip(zipPath, destDir) {
|
|
82
|
+
console.error("Extracting Chrome...");
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
fs.createReadStream(zipPath).pipe(unzipper.Extract({ path: destDir })).on("close", () => {
|
|
85
|
+
console.error("Extraction complete.");
|
|
86
|
+
resolve();
|
|
87
|
+
}).on("error", reject);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async function downloadChrome(options = {}) {
|
|
91
|
+
const version = options.version || process.env.SQUIDLER_CHROME_VERSION || DEFAULT_CHROME_VERSION;
|
|
92
|
+
const defaultCacheDir = path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "squidler", "chrome");
|
|
93
|
+
const cacheDir = options.cacheDir || defaultCacheDir;
|
|
94
|
+
const platform2 = getPlatform();
|
|
95
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
96
|
+
const versionDir = path.join(cacheDir, version);
|
|
97
|
+
const executablePath = getChromePath(versionDir, platform2);
|
|
98
|
+
if (fs.existsSync(executablePath)) {
|
|
99
|
+
console.error(`Using cached Chrome ${version}`);
|
|
100
|
+
return { executablePath, version };
|
|
101
|
+
}
|
|
102
|
+
const url = `https://storage.googleapis.com/chrome-for-testing-public/${version}/${platform2}/chrome-headless-shell-${platform2}.zip`;
|
|
103
|
+
const zipPath = path.join(cacheDir, `chrome-${version}-${platform2}.zip`);
|
|
104
|
+
console.error(`Downloading Chrome ${version} for ${platform2}...`);
|
|
105
|
+
try {
|
|
106
|
+
await downloadFile(url, zipPath);
|
|
107
|
+
await extractZip(zipPath, versionDir);
|
|
108
|
+
fs.unlinkSync(zipPath);
|
|
109
|
+
if (platform2 !== "win64") {
|
|
110
|
+
fs.chmodSync(executablePath, 493);
|
|
111
|
+
}
|
|
112
|
+
console.error(`Chrome ${version} installed at: ${executablePath}`);
|
|
113
|
+
return { executablePath, version };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (fs.existsSync(zipPath)) {
|
|
116
|
+
fs.unlinkSync(zipPath);
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/version.ts
|
|
123
|
+
import { readFileSync } from "fs";
|
|
124
|
+
import { dirname, join as join2 } from "path";
|
|
125
|
+
import { fileURLToPath } from "url";
|
|
126
|
+
var __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
127
|
+
var pkg = JSON.parse(readFileSync(join2(__dirname2, "..", "package.json"), "utf-8"));
|
|
128
|
+
var VERSION = pkg.version;
|
|
129
|
+
|
|
130
|
+
// src/auth/token-store.ts
|
|
131
|
+
import * as fs2 from "fs";
|
|
132
|
+
import * as path2 from "path";
|
|
133
|
+
import * as os2 from "os";
|
|
134
|
+
function getDataDir() {
|
|
135
|
+
const base = process.env.XDG_DATA_HOME || path2.join(os2.homedir(), ".local", "share");
|
|
136
|
+
return path2.join(base, "squidler");
|
|
137
|
+
}
|
|
138
|
+
function getAuthFilePath() {
|
|
139
|
+
return path2.join(getDataDir(), "auth.json");
|
|
140
|
+
}
|
|
141
|
+
function readStore() {
|
|
142
|
+
const filePath = getAuthFilePath();
|
|
143
|
+
try {
|
|
144
|
+
const data = fs2.readFileSync(filePath, "utf-8");
|
|
145
|
+
return JSON.parse(data);
|
|
146
|
+
} catch {
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function writeStore(store) {
|
|
151
|
+
const dir = getDataDir();
|
|
152
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
153
|
+
const filePath = getAuthFilePath();
|
|
154
|
+
fs2.writeFileSync(filePath, JSON.stringify(store, null, 2), { mode: 384 });
|
|
155
|
+
}
|
|
156
|
+
function loadStoredAuth(serverUrl) {
|
|
157
|
+
const store = readStore();
|
|
158
|
+
return store[serverUrl] || null;
|
|
159
|
+
}
|
|
160
|
+
function saveStoredAuth(auth) {
|
|
161
|
+
const store = readStore();
|
|
162
|
+
store[auth.server_url] = auth;
|
|
163
|
+
writeStore(store);
|
|
164
|
+
}
|
|
165
|
+
function clearStoredAuth(serverUrl) {
|
|
166
|
+
const store = readStore();
|
|
167
|
+
delete store[serverUrl];
|
|
168
|
+
writeStore(store);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/auth/oauth.ts
|
|
172
|
+
import * as crypto from "crypto";
|
|
173
|
+
import * as os3 from "os";
|
|
174
|
+
import { spawnSync } from "child_process";
|
|
175
|
+
|
|
176
|
+
// src/auth/callback-server.ts
|
|
177
|
+
import * as http from "http";
|
|
178
|
+
var SUCCESS_HTML = `<!DOCTYPE html>
|
|
179
|
+
<html><head><title>Authentication Successful</title></head>
|
|
180
|
+
<body style="font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
|
|
181
|
+
<div style="text-align:center"><h1>Authenticated!</h1><p>You can close this tab and return to your terminal.</p></div>
|
|
182
|
+
</body></html>`;
|
|
183
|
+
var ERROR_HTML = (msg) => `<!DOCTYPE html>
|
|
184
|
+
<html><head><title>Authentication Failed</title></head>
|
|
185
|
+
<body style="font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
|
|
186
|
+
<div style="text-align:center"><h1>Authentication Failed</h1><p>${msg}</p></div>
|
|
187
|
+
</body></html>`;
|
|
188
|
+
function startCallbackServer() {
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
let onResult;
|
|
191
|
+
let onError;
|
|
192
|
+
const resultPromise = new Promise((res, rej) => {
|
|
193
|
+
onResult = res;
|
|
194
|
+
onError = rej;
|
|
195
|
+
});
|
|
196
|
+
const server = http.createServer((req, res) => {
|
|
197
|
+
const url = new URL(req.url || "/", `http://127.0.0.1`);
|
|
198
|
+
if (url.pathname !== "/callback") {
|
|
199
|
+
res.writeHead(404);
|
|
200
|
+
res.end("Not found");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const error = url.searchParams.get("error");
|
|
204
|
+
if (error) {
|
|
205
|
+
const desc = url.searchParams.get("error_description") || error;
|
|
206
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
207
|
+
res.end(ERROR_HTML(desc));
|
|
208
|
+
onError(new Error(`OAuth error: ${desc}`));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const code = url.searchParams.get("code");
|
|
212
|
+
const state = url.searchParams.get("state");
|
|
213
|
+
if (!code || !state) {
|
|
214
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
215
|
+
res.end(ERROR_HTML("Missing code or state parameter"));
|
|
216
|
+
onError(new Error("Missing code or state in callback"));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
220
|
+
res.end(SUCCESS_HTML);
|
|
221
|
+
onResult({ code, state });
|
|
222
|
+
});
|
|
223
|
+
server.unref();
|
|
224
|
+
const timeout = setTimeout(() => {
|
|
225
|
+
server.close();
|
|
226
|
+
onError(new Error("Authentication timed out (5 minutes)"));
|
|
227
|
+
}, 5 * 60 * 1000);
|
|
228
|
+
server.on("error", reject);
|
|
229
|
+
server.listen(0, "127.0.0.1", () => {
|
|
230
|
+
const address = server.address();
|
|
231
|
+
if (!address || typeof address === "string") {
|
|
232
|
+
reject(new Error("Could not get callback server port"));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
resolve({
|
|
236
|
+
port: address.port,
|
|
237
|
+
waitForCallback: () => resultPromise,
|
|
238
|
+
close: () => {
|
|
239
|
+
clearTimeout(timeout);
|
|
240
|
+
server.close();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/auth/oauth.ts
|
|
248
|
+
async function discover(serverUrl) {
|
|
249
|
+
const res = await fetch(`${serverUrl}/.well-known/oauth-authorization-server`);
|
|
250
|
+
if (!res.ok) {
|
|
251
|
+
throw new Error(`OAuth discovery failed: HTTP ${res.status}`);
|
|
252
|
+
}
|
|
253
|
+
const metadata = await res.json();
|
|
254
|
+
if (!metadata.authorization_endpoint || !metadata.token_endpoint || !metadata.registration_endpoint) {
|
|
255
|
+
throw new Error("OAuth discovery response missing required endpoints");
|
|
256
|
+
}
|
|
257
|
+
return metadata;
|
|
258
|
+
}
|
|
259
|
+
async function registerClient(registrationEndpoint, redirectUri) {
|
|
260
|
+
const res = await fetch(registrationEndpoint, {
|
|
261
|
+
method: "POST",
|
|
262
|
+
headers: { "Content-Type": "application/json" },
|
|
263
|
+
body: JSON.stringify({
|
|
264
|
+
client_name: "squidler-mcp-cli",
|
|
265
|
+
redirect_uris: [redirectUri],
|
|
266
|
+
grant_types: ["authorization_code"],
|
|
267
|
+
response_types: ["code"],
|
|
268
|
+
token_endpoint_auth_method: "client_secret_post"
|
|
269
|
+
})
|
|
270
|
+
});
|
|
271
|
+
if (!res.ok) {
|
|
272
|
+
throw new Error(`Client registration failed: HTTP ${res.status}`);
|
|
273
|
+
}
|
|
274
|
+
return await res.json();
|
|
275
|
+
}
|
|
276
|
+
function generatePKCE() {
|
|
277
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
278
|
+
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
279
|
+
return { verifier, challenge };
|
|
280
|
+
}
|
|
281
|
+
function openBrowser(url) {
|
|
282
|
+
const platform3 = os3.platform();
|
|
283
|
+
const commands = platform3 === "darwin" ? [["open", [url]]] : platform3 === "win32" ? [["cmd", ["/c", "start", "", url]]] : [
|
|
284
|
+
["xdg-open", [url]],
|
|
285
|
+
["sensible-browser", [url]],
|
|
286
|
+
["x-www-browser", [url]]
|
|
287
|
+
];
|
|
288
|
+
for (const [cmd, args] of commands) {
|
|
289
|
+
const result = spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 });
|
|
290
|
+
if (!result.error && result.status === 0) {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
async function exchangeToken(tokenEndpoint, params) {
|
|
297
|
+
const body = new URLSearchParams({
|
|
298
|
+
grant_type: "authorization_code",
|
|
299
|
+
code: params.code,
|
|
300
|
+
redirect_uri: params.redirectUri,
|
|
301
|
+
client_id: params.clientId,
|
|
302
|
+
client_secret: params.clientSecret,
|
|
303
|
+
code_verifier: params.codeVerifier
|
|
304
|
+
});
|
|
305
|
+
const res = await fetch(tokenEndpoint, {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
308
|
+
body: body.toString()
|
|
309
|
+
});
|
|
310
|
+
if (!res.ok) {
|
|
311
|
+
const text = await res.text();
|
|
312
|
+
throw new Error(`Token exchange failed: HTTP ${res.status} - ${text}`);
|
|
313
|
+
}
|
|
314
|
+
const data = await res.json();
|
|
315
|
+
if (!data.access_token) {
|
|
316
|
+
throw new Error("Token response missing access_token");
|
|
317
|
+
}
|
|
318
|
+
return data.access_token;
|
|
319
|
+
}
|
|
320
|
+
async function authenticateWithOAuth(serverUrl) {
|
|
321
|
+
console.error("Discovering OAuth endpoints...");
|
|
322
|
+
const metadata = await discover(serverUrl);
|
|
323
|
+
const callback = await startCallbackServer();
|
|
324
|
+
const redirectUri = `http://127.0.0.1:${callback.port}/callback`;
|
|
325
|
+
try {
|
|
326
|
+
console.error("Registering client...");
|
|
327
|
+
const client = await registerClient(metadata.registration_endpoint, redirectUri);
|
|
328
|
+
const pkce = generatePKCE();
|
|
329
|
+
const state = crypto.randomBytes(16).toString("base64url");
|
|
330
|
+
const authUrl = new URL(metadata.authorization_endpoint);
|
|
331
|
+
authUrl.searchParams.set("response_type", "code");
|
|
332
|
+
authUrl.searchParams.set("client_id", client.client_id);
|
|
333
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
334
|
+
authUrl.searchParams.set("state", state);
|
|
335
|
+
authUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
336
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
337
|
+
const opened = openBrowser(authUrl.toString());
|
|
338
|
+
if (opened) {
|
|
339
|
+
console.error("Browser opened for authentication. Waiting...");
|
|
340
|
+
} else {
|
|
341
|
+
console.error(`Open this URL in your browser to authenticate:
|
|
342
|
+
|
|
343
|
+
${authUrl.toString()}
|
|
344
|
+
|
|
345
|
+
Waiting for authentication...`);
|
|
346
|
+
}
|
|
347
|
+
const result = await callback.waitForCallback();
|
|
348
|
+
if (result.state !== state) {
|
|
349
|
+
throw new Error("OAuth state mismatch — possible CSRF attack");
|
|
350
|
+
}
|
|
351
|
+
console.error("Exchanging authorization code for token...");
|
|
352
|
+
const accessToken = await exchangeToken(metadata.token_endpoint, {
|
|
353
|
+
code: result.code,
|
|
354
|
+
redirectUri,
|
|
355
|
+
clientId: client.client_id,
|
|
356
|
+
clientSecret: client.client_secret,
|
|
357
|
+
codeVerifier: pkce.verifier
|
|
358
|
+
});
|
|
359
|
+
saveStoredAuth({
|
|
360
|
+
access_token: accessToken,
|
|
361
|
+
server_url: serverUrl,
|
|
362
|
+
created_at: new Date().toISOString()
|
|
363
|
+
});
|
|
364
|
+
return accessToken;
|
|
365
|
+
} finally {
|
|
366
|
+
callback.close();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export { __require, downloadChrome, VERSION, loadStoredAuth, clearStoredAuth, authenticateWithOAuth };
|