@deepsql/mcp 0.2.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 +42 -0
- package/bin/deepsql.js +13 -0
- package/claude_desktop_config.customer.example.json +17 -0
- package/codex_config.customer.example.toml +9 -0
- package/deepsql-phase1-lib.js +586 -0
- package/deepsql-phase1-server.js +280 -0
- package/package.json +26 -0
- package/src/api/client.js +93 -0
- package/src/auth/browser-flow.js +152 -0
- package/src/auth/device-flow.js +59 -0
- package/src/auth/pkce.js +41 -0
- package/src/auth/pkce.test.js +32 -0
- package/src/auth/store.js +152 -0
- package/src/auth/store.test.js +78 -0
- package/src/cli.js +158 -0
- package/src/cli.test.js +38 -0
- package/src/commands/_session.js +41 -0
- package/src/commands/ask.js +42 -0
- package/src/commands/config.js +42 -0
- package/src/commands/connections.js +29 -0
- package/src/commands/explain.js +40 -0
- package/src/commands/login.js +68 -0
- package/src/commands/logout.js +33 -0
- package/src/commands/mcp.js +31 -0
- package/src/commands/query.js +66 -0
- package/src/commands/schema.js +18 -0
- package/src/commands/whoami.js +23 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// DeepSQL Phase 1 MCP Server — stdio transport for read-only database access
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
TOOL_DEFINITIONS,
|
|
7
|
+
DeepSqlApiError,
|
|
8
|
+
buildToolError,
|
|
9
|
+
createConfigFromEnv,
|
|
10
|
+
handleToolCall,
|
|
11
|
+
} = require("./deepsql-phase1-lib");
|
|
12
|
+
|
|
13
|
+
const SERVER_INFO = {
|
|
14
|
+
name: "deepsql-phase1",
|
|
15
|
+
version: "0.1.0",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
class DeepSqlPhase1McpServer {
|
|
19
|
+
constructor(config) {
|
|
20
|
+
this.config = config;
|
|
21
|
+
this.inputBuffer = Buffer.alloc(0);
|
|
22
|
+
this.shuttingDown = false;
|
|
23
|
+
// "auto" until we observe the client's framing. The MCP spec mandates
|
|
24
|
+
// newline-delimited JSON-RPC over stdio (Claude Code, codex-cli, etc.),
|
|
25
|
+
// but older clients (legacy Claude Desktop, our own E2E driver) used
|
|
26
|
+
// LSP-style Content-Length framing. Detect on first frame and respond
|
|
27
|
+
// in the same format.
|
|
28
|
+
this.framing = "auto";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
start() {
|
|
32
|
+
process.stdin.on("data", (chunk) => {
|
|
33
|
+
this.inputBuffer = Buffer.concat([this.inputBuffer, chunk]);
|
|
34
|
+
this.processInputBuffer();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
process.stdin.on("end", () => {
|
|
38
|
+
process.exit(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
process.stdin.resume();
|
|
42
|
+
this.log(
|
|
43
|
+
`DeepSQL phase 1 MCP server started. Base URL=${this.config.baseUrl}, timeout=${this.config.timeoutMs}ms`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
log(message) {
|
|
48
|
+
process.stderr.write(`[deepsql-phase1] ${message}\n`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
detectFraming() {
|
|
52
|
+
// Skip leading whitespace to find the first meaningful byte.
|
|
53
|
+
let i = 0;
|
|
54
|
+
while (i < this.inputBuffer.length) {
|
|
55
|
+
const b = this.inputBuffer[i];
|
|
56
|
+
if (b !== 0x20 && b !== 0x09 && b !== 0x0a && b !== 0x0d) break;
|
|
57
|
+
i += 1;
|
|
58
|
+
}
|
|
59
|
+
if (i >= this.inputBuffer.length) return false;
|
|
60
|
+
// A leading `{` means newline-delimited JSON-RPC (the MCP spec format).
|
|
61
|
+
// Anything else (e.g. `C` from "Content-Length") is LSP framing.
|
|
62
|
+
this.framing = this.inputBuffer[i] === 0x7b ? "ndjson" : "lsp";
|
|
63
|
+
this.log(`Detected client framing: ${this.framing}`);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
processInputBuffer() {
|
|
68
|
+
if (this.framing === "auto" && !this.detectFraming()) return;
|
|
69
|
+
if (this.framing === "ndjson") return this.processNdjsonBuffer();
|
|
70
|
+
return this.processLspBuffer();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
processNdjsonBuffer() {
|
|
74
|
+
while (true) {
|
|
75
|
+
const nl = this.inputBuffer.indexOf(0x0a);
|
|
76
|
+
if (nl < 0) return;
|
|
77
|
+
const lineBuf = this.inputBuffer.slice(0, nl);
|
|
78
|
+
this.inputBuffer = this.inputBuffer.slice(nl + 1);
|
|
79
|
+
const line = lineBuf.toString("utf8").replace(/\r$/, "").trim();
|
|
80
|
+
if (!line) continue;
|
|
81
|
+
let message;
|
|
82
|
+
try {
|
|
83
|
+
message = JSON.parse(line);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
this.log(`Failed to parse incoming JSON: ${error.message}`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
void this.handleMessage(message);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
processLspBuffer() {
|
|
93
|
+
while (true) {
|
|
94
|
+
const crlfIndex = this.inputBuffer.indexOf("\r\n\r\n");
|
|
95
|
+
const lfIndex = this.inputBuffer.indexOf("\n\n");
|
|
96
|
+
let headerEnd = -1;
|
|
97
|
+
let separatorLength = 0;
|
|
98
|
+
|
|
99
|
+
if (crlfIndex >= 0 && (lfIndex < 0 || crlfIndex < lfIndex)) {
|
|
100
|
+
headerEnd = crlfIndex;
|
|
101
|
+
separatorLength = 4;
|
|
102
|
+
} else if (lfIndex >= 0) {
|
|
103
|
+
headerEnd = lfIndex;
|
|
104
|
+
separatorLength = 2;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (headerEnd < 0) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const headerText = this.inputBuffer.slice(0, headerEnd).toString("utf8");
|
|
112
|
+
const lengthMatch = headerText.match(/Content-Length:\s*(\d+)/i);
|
|
113
|
+
if (!lengthMatch) {
|
|
114
|
+
this.log("Received frame without Content-Length header; discarding.");
|
|
115
|
+
this.inputBuffer = this.inputBuffer.slice(headerEnd + separatorLength);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const contentLength = Number.parseInt(lengthMatch[1], 10);
|
|
120
|
+
const totalLength = headerEnd + separatorLength + contentLength;
|
|
121
|
+
if (this.inputBuffer.length < totalLength) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const bodyBuffer = this.inputBuffer.slice(
|
|
126
|
+
headerEnd + separatorLength,
|
|
127
|
+
totalLength,
|
|
128
|
+
);
|
|
129
|
+
this.inputBuffer = this.inputBuffer.slice(totalLength);
|
|
130
|
+
|
|
131
|
+
let message;
|
|
132
|
+
try {
|
|
133
|
+
message = JSON.parse(bodyBuffer.toString("utf8"));
|
|
134
|
+
} catch (error) {
|
|
135
|
+
this.log(`Failed to parse incoming JSON: ${error.message}`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
void this.handleMessage(message);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
send(payload) {
|
|
144
|
+
const body = JSON.stringify(payload);
|
|
145
|
+
if (this.framing === "lsp") {
|
|
146
|
+
const header = `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n`;
|
|
147
|
+
process.stdout.write(header + body);
|
|
148
|
+
} else {
|
|
149
|
+
// Default to newline-delimited JSON-RPC (MCP spec). Used when the
|
|
150
|
+
// framing is still "auto" (e.g. server-initiated message before any
|
|
151
|
+
// client frame, which Phase 1 doesn't actually do).
|
|
152
|
+
process.stdout.write(body + "\n");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
sendResult(id, result) {
|
|
157
|
+
this.send({
|
|
158
|
+
jsonrpc: "2.0",
|
|
159
|
+
id,
|
|
160
|
+
result,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
sendError(id, code, message, data) {
|
|
165
|
+
this.send({
|
|
166
|
+
jsonrpc: "2.0",
|
|
167
|
+
id,
|
|
168
|
+
error: {
|
|
169
|
+
code,
|
|
170
|
+
message,
|
|
171
|
+
...(data === undefined ? {} : { data }),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async handleMessage(message) {
|
|
177
|
+
const { id, method, params } = message || {};
|
|
178
|
+
|
|
179
|
+
if (!method) {
|
|
180
|
+
if (id !== undefined) {
|
|
181
|
+
this.sendError(id, -32600, "Invalid request");
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
switch (method) {
|
|
188
|
+
case "initialize": {
|
|
189
|
+
this.sendResult(id, {
|
|
190
|
+
protocolVersion: params?.protocolVersion || "2025-03-26",
|
|
191
|
+
capabilities: {
|
|
192
|
+
tools: {},
|
|
193
|
+
},
|
|
194
|
+
serverInfo: SERVER_INFO,
|
|
195
|
+
instructions:
|
|
196
|
+
"DeepSQL phase 1 MCP exposes read-only database access through DeepSQL. Prefer answer_question for high-level tasks. execute_readonly_sql and explain_readonly_sql reject mutating SQL.",
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case "notifications/initialized":
|
|
202
|
+
return;
|
|
203
|
+
|
|
204
|
+
case "ping":
|
|
205
|
+
this.sendResult(id, {});
|
|
206
|
+
return;
|
|
207
|
+
|
|
208
|
+
case "shutdown":
|
|
209
|
+
this.shuttingDown = true;
|
|
210
|
+
this.sendResult(id, {});
|
|
211
|
+
return;
|
|
212
|
+
|
|
213
|
+
case "exit":
|
|
214
|
+
process.exit(this.shuttingDown ? 0 : 1);
|
|
215
|
+
return;
|
|
216
|
+
|
|
217
|
+
case "tools/list":
|
|
218
|
+
this.sendResult(id, {
|
|
219
|
+
tools: TOOL_DEFINITIONS,
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
|
|
223
|
+
case "resources/list":
|
|
224
|
+
this.sendResult(id, {
|
|
225
|
+
resources: [],
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
|
|
229
|
+
case "prompts/list":
|
|
230
|
+
this.sendResult(id, {
|
|
231
|
+
prompts: [],
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
|
|
235
|
+
case "tools/call": {
|
|
236
|
+
const toolName = params?.name;
|
|
237
|
+
const toolArgs = params?.arguments || {};
|
|
238
|
+
|
|
239
|
+
if (!toolName) {
|
|
240
|
+
this.sendResult(id, buildToolError("Tool name is required."));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const result = await handleToolCall(this.config, toolName, toolArgs);
|
|
245
|
+
this.sendResult(id, result);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
default:
|
|
250
|
+
this.sendError(id, -32601, `Method not found: ${method}`);
|
|
251
|
+
}
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (error instanceof DeepSqlApiError) {
|
|
254
|
+
this.sendResult(
|
|
255
|
+
id,
|
|
256
|
+
buildToolError(`DeepSQL API error (${error.status || "unknown"}): ${error.message}`, {
|
|
257
|
+
status: error.status,
|
|
258
|
+
payload: error.payload,
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.log(error?.stack || error?.message || String(error));
|
|
265
|
+
if (id !== undefined) {
|
|
266
|
+
this.sendError(id, -32000, error?.message || "Internal server error");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (require.main === module) {
|
|
273
|
+
const server = new DeepSqlPhase1McpServer(createConfigFromEnv());
|
|
274
|
+
server.start();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = {
|
|
278
|
+
DeepSqlPhase1McpServer,
|
|
279
|
+
SERVER_INFO,
|
|
280
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deepsql/mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DeepSQL CLI and stdio MCP server for self-hosted deployments",
|
|
5
|
+
"bin": {
|
|
6
|
+
"deepsql": "./bin/deepsql.js",
|
|
7
|
+
"deepsql-mcp": "./deepsql-phase1-server.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./deepsql-phase1-server.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"README.md",
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"deepsql-phase1-server.js",
|
|
15
|
+
"deepsql-phase1-lib.js",
|
|
16
|
+
"claude_desktop_config.customer.example.json",
|
|
17
|
+
"codex_config.customer.example.toml"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test deepsql-phase1-lib.test.js src/**/*.test.js"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"license": "UNLICENSED"
|
|
26
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HTTP client used by the CLI. Wraps fetch with sane defaults, base-URL
|
|
5
|
+
* normalization, and consistent error shapes.
|
|
6
|
+
*
|
|
7
|
+
* Distinct from `deepsql-phase1-lib.js`'s `callDeepSqlApi` because the CLI
|
|
8
|
+
* needs:
|
|
9
|
+
* - profile-based base URL resolution (not env-only)
|
|
10
|
+
* - to talk to the unauthenticated /auth/cli endpoints
|
|
11
|
+
* - to stream responses for `ask`
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
class ApiError extends Error {
|
|
15
|
+
constructor(message, { status, body } = {}) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.body = body;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeBaseUrl(url) {
|
|
23
|
+
if (!url) throw new ApiError("No DeepSQL URL configured. Run `deepsql login --url <url>` first.");
|
|
24
|
+
return url.endsWith("/") ? url : `${url}/`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveUrl(baseUrl, path) {
|
|
28
|
+
const normalized = String(path || "").replace(/^\/+/, "");
|
|
29
|
+
// The backend mounts everything under /api. Accept paths with or without
|
|
30
|
+
// the prefix.
|
|
31
|
+
const withApi = normalized.startsWith("api/") ? normalized : `api/${normalized}`;
|
|
32
|
+
return new URL(withApi, normalizeBaseUrl(baseUrl)).toString();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function request(baseUrl, pathOrUrl, { method = "GET", json, headers, token, timeoutMs = 120000, query } = {}) {
|
|
36
|
+
let url;
|
|
37
|
+
if (typeof pathOrUrl === "string" && /^https?:\/\//i.test(pathOrUrl)) {
|
|
38
|
+
url = pathOrUrl;
|
|
39
|
+
} else {
|
|
40
|
+
url = resolveUrl(baseUrl, pathOrUrl);
|
|
41
|
+
}
|
|
42
|
+
if (query && typeof query === "object") {
|
|
43
|
+
const u = new URL(url);
|
|
44
|
+
for (const [key, value] of Object.entries(query)) {
|
|
45
|
+
if (value == null) continue;
|
|
46
|
+
u.searchParams.set(key, String(value));
|
|
47
|
+
}
|
|
48
|
+
url = u.toString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const requestHeaders = {
|
|
52
|
+
Accept: "application/json",
|
|
53
|
+
...(headers || {}),
|
|
54
|
+
};
|
|
55
|
+
if (token) requestHeaders.Authorization = `Bearer ${token}`;
|
|
56
|
+
if (json != null) requestHeaders["Content-Type"] = "application/json";
|
|
57
|
+
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
60
|
+
let response;
|
|
61
|
+
try {
|
|
62
|
+
response = await fetch(url, {
|
|
63
|
+
method,
|
|
64
|
+
headers: requestHeaders,
|
|
65
|
+
body: json == null ? undefined : JSON.stringify(json),
|
|
66
|
+
signal: controller.signal,
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err.name === "AbortError") {
|
|
70
|
+
throw new ApiError(`Request to ${url} timed out after ${timeoutMs}ms`);
|
|
71
|
+
}
|
|
72
|
+
throw new ApiError(`Network error contacting ${url}: ${err.message}`);
|
|
73
|
+
} finally {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const text = await response.text();
|
|
78
|
+
let body = null;
|
|
79
|
+
if (text) {
|
|
80
|
+
try {
|
|
81
|
+
body = JSON.parse(text);
|
|
82
|
+
} catch {
|
|
83
|
+
body = text;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
const message = (body && typeof body === "object" && (body.message || body.error)) || `HTTP ${response.status}`;
|
|
88
|
+
throw new ApiError(message, { status: response.status, body });
|
|
89
|
+
}
|
|
90
|
+
return body;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { ApiError, request, resolveUrl, normalizeBaseUrl };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PKCE + loopback redirect flow.
|
|
5
|
+
*
|
|
6
|
+
* 1. Generate verifier/challenge/state.
|
|
7
|
+
* 2. Bind a one-shot HTTP server on 127.0.0.1:<random-port>.
|
|
8
|
+
* 3. POST /auth/cli/authorize → {authorization_id, authorize_url}.
|
|
9
|
+
* 4. Open the authorize URL in the user's browser.
|
|
10
|
+
* 5. Wait for the loopback callback (code + state). Verify state matches.
|
|
11
|
+
* 6. POST /auth/cli/exchange → MCP token.
|
|
12
|
+
*
|
|
13
|
+
* The loopback server only responds to the single expected callback path; any
|
|
14
|
+
* other request gets a 404 to avoid being a useful open-redirect target.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const http = require("node:http");
|
|
18
|
+
const { spawn } = require("node:child_process");
|
|
19
|
+
|
|
20
|
+
const { challengeFor, generateState, generateVerifier } = require("./pkce");
|
|
21
|
+
const { request } = require("../api/client");
|
|
22
|
+
|
|
23
|
+
const CALLBACK_PATH = "/cb";
|
|
24
|
+
const SUCCESS_HTML = `<!doctype html>
|
|
25
|
+
<html><head><meta charset="utf-8"><title>DeepSQL CLI authorized</title>
|
|
26
|
+
<style>body{font:16px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}h1{font-size:24px;margin:0 0 12px}p{color:#555}</style>
|
|
27
|
+
</head><body><h1>You're all set.</h1><p>You can close this tab and return to your terminal.</p></body></html>`;
|
|
28
|
+
|
|
29
|
+
const FAILURE_HTML = `<!doctype html>
|
|
30
|
+
<html><head><meta charset="utf-8"><title>DeepSQL CLI authorization failed</title>
|
|
31
|
+
<style>body{font:16px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}h1{font-size:24px;margin:0 0 12px;color:#b91c1c}p{color:#555}</style>
|
|
32
|
+
</head><body><h1>Authorization failed.</h1><p>Return to your terminal for details.</p></body></html>`;
|
|
33
|
+
|
|
34
|
+
function startLoopbackServer() {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const callbackPromise = new Promise((resolveCallback, rejectCallback) => {
|
|
37
|
+
const server = http.createServer((req, res) => {
|
|
38
|
+
const url = new URL(req.url, `http://127.0.0.1`);
|
|
39
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
40
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
41
|
+
res.end("Not found");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const code = url.searchParams.get("code");
|
|
45
|
+
const state = url.searchParams.get("state");
|
|
46
|
+
const error = url.searchParams.get("error");
|
|
47
|
+
if (error || !code) {
|
|
48
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
49
|
+
res.end(FAILURE_HTML);
|
|
50
|
+
rejectCallback(new Error(error || "Loopback callback missing `code` parameter"));
|
|
51
|
+
} else {
|
|
52
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
53
|
+
res.end(SUCCESS_HTML);
|
|
54
|
+
resolveCallback({ code, state });
|
|
55
|
+
}
|
|
56
|
+
// Server can shut down once we've handled the one expected request.
|
|
57
|
+
setImmediate(() => server.close());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
server.on("error", reject);
|
|
61
|
+
server.listen(0, "127.0.0.1", () => {
|
|
62
|
+
const { port } = server.address();
|
|
63
|
+
resolve({
|
|
64
|
+
port,
|
|
65
|
+
redirectUri: `http://127.0.0.1:${port}${CALLBACK_PATH}`,
|
|
66
|
+
callback: callbackPromise,
|
|
67
|
+
close: () => server.close(),
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function openInBrowser(url) {
|
|
75
|
+
const opener =
|
|
76
|
+
process.platform === "darwin"
|
|
77
|
+
? ["open", [url]]
|
|
78
|
+
: process.platform === "win32"
|
|
79
|
+
? ["cmd", ["/c", "start", '""', url]]
|
|
80
|
+
: ["xdg-open", [url]];
|
|
81
|
+
try {
|
|
82
|
+
const child = spawn(opener[0], opener[1], { stdio: "ignore", detached: true });
|
|
83
|
+
child.on("error", () => {});
|
|
84
|
+
child.unref();
|
|
85
|
+
} catch {
|
|
86
|
+
// Fall through silently — caller already prints the URL.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function runBrowserFlow({
|
|
91
|
+
baseUrl,
|
|
92
|
+
hostname,
|
|
93
|
+
clientLabel,
|
|
94
|
+
openBrowser = true,
|
|
95
|
+
log = () => {},
|
|
96
|
+
timeoutMs = 5 * 60 * 1000,
|
|
97
|
+
}) {
|
|
98
|
+
const verifier = generateVerifier();
|
|
99
|
+
const challenge = challengeFor(verifier);
|
|
100
|
+
const state = generateState();
|
|
101
|
+
|
|
102
|
+
const server = await startLoopbackServer();
|
|
103
|
+
log(`listening for callback on ${server.redirectUri}`);
|
|
104
|
+
|
|
105
|
+
let started;
|
|
106
|
+
try {
|
|
107
|
+
started = await request(baseUrl, "/auth/cli/authorize", {
|
|
108
|
+
method: "POST",
|
|
109
|
+
json: {
|
|
110
|
+
redirectUri: server.redirectUri,
|
|
111
|
+
codeChallenge: challenge,
|
|
112
|
+
state,
|
|
113
|
+
hostname,
|
|
114
|
+
clientLabel,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
server.close();
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
log(`open ${started.authorize_url}`);
|
|
123
|
+
if (openBrowser) openInBrowser(started.authorize_url);
|
|
124
|
+
|
|
125
|
+
const callbackTimer = setTimeout(() => {
|
|
126
|
+
server.close();
|
|
127
|
+
}, timeoutMs);
|
|
128
|
+
|
|
129
|
+
let callback;
|
|
130
|
+
try {
|
|
131
|
+
callback = await server.callback;
|
|
132
|
+
} finally {
|
|
133
|
+
clearTimeout(callbackTimer);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (callback.state && callback.state !== state) {
|
|
137
|
+
throw new Error("State mismatch — possible CSRF; aborting.");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const issued = await request(baseUrl, "/auth/cli/exchange", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
json: {
|
|
143
|
+
authorizationId: started.authorization_id,
|
|
144
|
+
code: callback.code,
|
|
145
|
+
codeVerifier: verifier,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return issued;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { runBrowserFlow, startLoopbackServer };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OAuth 2.0 device-code flow (RFC 8628 shape, adapted for self-hosted DeepSQL).
|
|
5
|
+
*
|
|
6
|
+
* 1. POST /auth/cli/device/code → {device_code, user_code, verification_uri, interval, expires_in}
|
|
7
|
+
* 2. Print the user_code + verification_uri.
|
|
8
|
+
* 3. Poll POST /auth/cli/device/token with {deviceCode}.
|
|
9
|
+
* - 200 → token issued
|
|
10
|
+
* - 428 (PRECONDITION_REQUIRED) → keep polling
|
|
11
|
+
* - 429 → bump interval (`slow_down`)
|
|
12
|
+
* - 410 → expired
|
|
13
|
+
* - 403 → user denied
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { ApiError, request } = require("../api/client");
|
|
17
|
+
|
|
18
|
+
function sleep(ms) {
|
|
19
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function runDeviceFlow({ baseUrl, hostname, clientLabel, log = () => {} }) {
|
|
23
|
+
const started = await request(baseUrl, "/auth/cli/device/code", {
|
|
24
|
+
method: "POST",
|
|
25
|
+
json: { hostname, clientLabel },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
log(
|
|
29
|
+
`Visit ${started.verification_uri} and enter code:\n\n ${started.user_code}\n\n` +
|
|
30
|
+
`Waiting for approval (expires in ${Math.round((started.expires_in || 900) / 60)} min)…`,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
let interval = (started.interval || 5) * 1000;
|
|
34
|
+
const deadline = Date.now() + (started.expires_in || 900) * 1000;
|
|
35
|
+
|
|
36
|
+
while (Date.now() < deadline) {
|
|
37
|
+
await sleep(interval);
|
|
38
|
+
try {
|
|
39
|
+
const issued = await request(baseUrl, "/auth/cli/device/token", {
|
|
40
|
+
method: "POST",
|
|
41
|
+
json: { deviceCode: started.device_code },
|
|
42
|
+
});
|
|
43
|
+
return issued;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (!(err instanceof ApiError)) throw err;
|
|
46
|
+
if (err.status === 428) continue; // authorization_pending
|
|
47
|
+
if (err.status === 429) {
|
|
48
|
+
interval = Math.min(interval * 2, 30000);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (err.status === 410) throw new Error("Device code expired before approval. Run `deepsql login` again.");
|
|
52
|
+
if (err.status === 403) throw new Error("Authorization was denied.");
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw new Error("Device authorization timed out.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { runDeviceFlow };
|
package/src/auth/pkce.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PKCE helpers (RFC 7636).
|
|
5
|
+
*
|
|
6
|
+
* verifier = high-entropy random string (43–128 chars from the unreserved set)
|
|
7
|
+
* challenge = base64url(sha256(verifier)) (S256 method)
|
|
8
|
+
*
|
|
9
|
+
* Splitting these out so the CLI tests can exercise the math without spinning
|
|
10
|
+
* up an HTTP server.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const crypto = require("node:crypto");
|
|
14
|
+
|
|
15
|
+
function base64UrlEncode(buffer) {
|
|
16
|
+
return buffer
|
|
17
|
+
.toString("base64")
|
|
18
|
+
.replaceAll("+", "-")
|
|
19
|
+
.replaceAll("/", "_")
|
|
20
|
+
.replaceAll("=", "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function generateVerifier(bytes = 48) {
|
|
24
|
+
return base64UrlEncode(crypto.randomBytes(bytes));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function challengeFor(verifier) {
|
|
28
|
+
const hash = crypto.createHash("sha256").update(verifier).digest();
|
|
29
|
+
return base64UrlEncode(hash);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function generateState() {
|
|
33
|
+
return base64UrlEncode(crypto.randomBytes(16));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = {
|
|
37
|
+
base64UrlEncode,
|
|
38
|
+
challengeFor,
|
|
39
|
+
generateState,
|
|
40
|
+
generateVerifier,
|
|
41
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const crypto = require("node:crypto");
|
|
6
|
+
|
|
7
|
+
const { challengeFor, generateState, generateVerifier, base64UrlEncode } = require("./pkce");
|
|
8
|
+
|
|
9
|
+
test("generateVerifier returns base64url with no padding", () => {
|
|
10
|
+
const verifier = generateVerifier();
|
|
11
|
+
assert.match(verifier, /^[A-Za-z0-9_-]+$/);
|
|
12
|
+
assert.ok(verifier.length >= 43, "verifier must meet RFC 7636 length minimum");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("challengeFor matches the spec: base64url(sha256(verifier))", () => {
|
|
16
|
+
const verifier = "test-verifier-with-enough-entropy-for-rfc7636";
|
|
17
|
+
const expected = base64UrlEncode(crypto.createHash("sha256").update(verifier).digest());
|
|
18
|
+
assert.equal(challengeFor(verifier), expected);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("two verifiers produce different challenges", () => {
|
|
22
|
+
const a = generateVerifier();
|
|
23
|
+
const b = generateVerifier();
|
|
24
|
+
assert.notEqual(a, b);
|
|
25
|
+
assert.notEqual(challengeFor(a), challengeFor(b));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("generateState returns base64url state of meaningful length", () => {
|
|
29
|
+
const state = generateState();
|
|
30
|
+
assert.match(state, /^[A-Za-z0-9_-]+$/);
|
|
31
|
+
assert.ok(state.length >= 16);
|
|
32
|
+
});
|