ssh-mcp-pro 1.1.2 → 1.1.4
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/CHANGELOG.md +20 -0
- package/README.md +23 -1
- package/dist/config-parsers.d.ts +17 -0
- package/dist/config-parsers.d.ts.map +1 -0
- package/dist/config-parsers.js +86 -0
- package/dist/config-parsers.js.map +1 -0
- package/dist/config.d.ts +3 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -58
- package/dist/config.js.map +1 -1
- package/dist/ensure-pkg.d.ts +9 -0
- package/dist/ensure-pkg.d.ts.map +1 -0
- package/dist/ensure-pkg.js +105 -0
- package/dist/ensure-pkg.js.map +1 -0
- package/dist/ensure.d.ts.map +1 -1
- package/dist/ensure.js +5 -106
- package/dist/ensure.js.map +1 -1
- package/dist/fs-sftp.d.ts +58 -0
- package/dist/fs-sftp.d.ts.map +1 -0
- package/dist/fs-sftp.js +184 -0
- package/dist/fs-sftp.js.map +1 -0
- package/dist/fs-tools.d.ts.map +1 -1
- package/dist/fs-tools.js +2 -144
- package/dist/fs-tools.js.map +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +1 -1
- package/dist/remote/agent-handler.d.ts +36 -0
- package/dist/remote/agent-handler.d.ts.map +1 -0
- package/dist/remote/agent-handler.js +255 -0
- package/dist/remote/agent-handler.js.map +1 -0
- package/dist/remote/control-plane.d.ts +4 -17
- package/dist/remote/control-plane.d.ts.map +1 -1
- package/dist/remote/control-plane.js +23 -657
- package/dist/remote/control-plane.js.map +1 -1
- package/dist/remote/http-util.d.ts +29 -0
- package/dist/remote/http-util.d.ts.map +1 -0
- package/dist/remote/http-util.js +159 -0
- package/dist/remote/http-util.js.map +1 -0
- package/dist/remote/oauth-handler.d.ts +47 -0
- package/dist/remote/oauth-handler.d.ts.map +1 -0
- package/dist/remote/oauth-handler.js +296 -0
- package/dist/remote/oauth-handler.js.map +1 -0
- package/dist/session-auth.d.ts +39 -0
- package/dist/session-auth.d.ts.map +1 -0
- package/dist/session-auth.js +148 -0
- package/dist/session-auth.js.map +1 -0
- package/dist/session.d.ts +25 -20
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +88 -159
- package/dist/session.js.map +1 -1
- package/dist/tunnel.d.ts.map +1 -1
- package/dist/tunnel.js +46 -9
- package/dist/tunnel.js.map +1 -1
- package/docs/audit/2026-06-05-ecosystem-audit.md +1 -1
- package/docs/governance/issue-taxonomy.json +5 -0
- package/mcp.json +1 -1
- package/package.json +21 -15
- package/registry/ssh-mcp-pro/mcp.json +1 -1
- package/server.json +3 -3
|
@@ -1,168 +1,17 @@
|
|
|
1
|
-
import { createPublicKey } from "node:crypto";
|
|
2
1
|
import { URL } from "node:url";
|
|
3
|
-
import {
|
|
4
|
-
import { ensurePemKeyPair, hashSecret, id, issueAccessToken, keyId, loadJwtKeyPair, nowIso, publicJwkFromPem, randomToken, sha256Base64Url, signEnvelope, verifyEnvelope, verifyRemoteAccessToken, } from "./crypto.js";
|
|
2
|
+
import { ensurePemKeyPair, hashSecret, id, keyId, loadJwtKeyPair, nowIso, randomToken, signEnvelope, verifyRemoteAccessToken, } from "./crypto.js";
|
|
5
3
|
import { loadRemoteConfig } from "./config.js";
|
|
4
|
+
import { isRecord, asString, negotiateMcpProtocolVersion, quotePosixArg, quotePowerShellArg, addNoStore, safeError, isValidEd25519PublicKey, readJson, sanitizeAgent, } from "./http-util.js";
|
|
6
5
|
import { listRemoteToolDescriptors } from "./mcp-tools.js";
|
|
7
6
|
import { createAgentPolicy, mergeCustomPolicy } from "./policy.js";
|
|
8
|
-
import {
|
|
9
|
-
import { hasCapability
|
|
7
|
+
import { parseAgentHostMetadata } from "./schemas.js";
|
|
8
|
+
import { hasCapability } from "./scopes.js";
|
|
10
9
|
import { RemoteStore } from "./store.js";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
10
|
+
import { OAuthHandler } from "./oauth-handler.js";
|
|
11
|
+
import { AgentWebSocketHandler, } from "./agent-handler.js";
|
|
12
|
+
import { TOOL_CAPABILITY_MAP } from "./types.js";
|
|
13
|
+
import { jsonResponse } from "./util.js";
|
|
14
14
|
import { SERVER_VERSION } from "../mcp.js";
|
|
15
|
-
const AGENT_NONCE_TTL_MS = 300_000;
|
|
16
|
-
const MAX_AGENT_CONNECTION_NONCES = 4096;
|
|
17
|
-
function isRecord(value) {
|
|
18
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
19
|
-
}
|
|
20
|
-
function asString(value) {
|
|
21
|
-
return typeof value === "string" ? value : undefined;
|
|
22
|
-
}
|
|
23
|
-
function negotiateMcpProtocolVersion(params) {
|
|
24
|
-
const requestedVersion = isRecord(params) ? asString(params.protocolVersion) : undefined;
|
|
25
|
-
return requestedVersion && SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
|
|
26
|
-
? requestedVersion
|
|
27
|
-
: LATEST_PROTOCOL_VERSION;
|
|
28
|
-
}
|
|
29
|
-
function quotePosixArg(value) {
|
|
30
|
-
return `'${value.replace(/'/gu, `'"'"'`)}'`;
|
|
31
|
-
}
|
|
32
|
-
function quotePowerShellArg(value) {
|
|
33
|
-
return `'${value.replace(/'/gu, "''")}'`;
|
|
34
|
-
}
|
|
35
|
-
function asStringArray(value) {
|
|
36
|
-
if (!Array.isArray(value)) {
|
|
37
|
-
return [];
|
|
38
|
-
}
|
|
39
|
-
return value.filter((item) => typeof item === "string");
|
|
40
|
-
}
|
|
41
|
-
function pruneNonceWindow(nonces, now = Date.now()) {
|
|
42
|
-
for (const [nonce, expiresAt] of nonces.entries()) {
|
|
43
|
-
if (expiresAt <= now) {
|
|
44
|
-
nonces.delete(nonce);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
function hasSeenNonce(nonces, nonce, now = Date.now()) {
|
|
49
|
-
pruneNonceWindow(nonces, now);
|
|
50
|
-
return nonces.has(nonce);
|
|
51
|
-
}
|
|
52
|
-
function rememberNonce(nonces, nonce, now = Date.now()) {
|
|
53
|
-
pruneNonceWindow(nonces, now);
|
|
54
|
-
nonces.set(nonce, now + AGENT_NONCE_TTL_MS);
|
|
55
|
-
while (nonces.size > MAX_AGENT_CONNECTION_NONCES) {
|
|
56
|
-
const oldest = nonces.keys().next().value;
|
|
57
|
-
if (typeof oldest !== "string") {
|
|
58
|
-
break;
|
|
59
|
-
}
|
|
60
|
-
nonces.delete(oldest);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
function addNoStore(headers = {}) {
|
|
64
|
-
return {
|
|
65
|
-
"Cache-Control": "no-store",
|
|
66
|
-
Pragma: "no-cache",
|
|
67
|
-
...headers,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
function redirect(res, location) {
|
|
71
|
-
res.writeHead(302, { Location: location, "Cache-Control": "no-store" });
|
|
72
|
-
res.end();
|
|
73
|
-
}
|
|
74
|
-
function safeError(code, message, status = 400) {
|
|
75
|
-
return { code, message, status };
|
|
76
|
-
}
|
|
77
|
-
function isValidEd25519PublicKey(publicKeyPem) {
|
|
78
|
-
try {
|
|
79
|
-
return createPublicKey(publicKeyPem).asymmetricKeyType === "ed25519";
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
return false;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
async function readBody(req, maxBytes = 1_000_000) {
|
|
86
|
-
const chunks = [];
|
|
87
|
-
let size = 0;
|
|
88
|
-
for await (const chunk of req) {
|
|
89
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
90
|
-
size += buffer.byteLength;
|
|
91
|
-
if (size > maxBytes) {
|
|
92
|
-
throw safeError("FORBIDDEN", "Request body is too large", 413);
|
|
93
|
-
}
|
|
94
|
-
chunks.push(buffer);
|
|
95
|
-
}
|
|
96
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
97
|
-
}
|
|
98
|
-
async function readJson(req) {
|
|
99
|
-
const raw = await readBody(req);
|
|
100
|
-
if (!raw.trim()) {
|
|
101
|
-
return {};
|
|
102
|
-
}
|
|
103
|
-
const parsed = JSON.parse(raw);
|
|
104
|
-
if (!isRecord(parsed)) {
|
|
105
|
-
throw safeError("INTERNAL_ERROR", "Expected JSON object");
|
|
106
|
-
}
|
|
107
|
-
return parsed;
|
|
108
|
-
}
|
|
109
|
-
async function readJsonOrForm(req) {
|
|
110
|
-
const raw = await readBody(req);
|
|
111
|
-
const contentType = req.headers["content-type"] ?? "";
|
|
112
|
-
if (String(contentType).includes("application/json")) {
|
|
113
|
-
const parsed = raw ? JSON.parse(raw) : {};
|
|
114
|
-
if (!isRecord(parsed)) {
|
|
115
|
-
throw safeError("INTERNAL_ERROR", "Expected JSON object");
|
|
116
|
-
}
|
|
117
|
-
return Object.fromEntries(Object.entries(parsed).map(([key, value]) => [
|
|
118
|
-
key,
|
|
119
|
-
typeof value === "string" ? value : String(value ?? ""),
|
|
120
|
-
]));
|
|
121
|
-
}
|
|
122
|
-
try {
|
|
123
|
-
return formDecode(raw);
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
throw safeError("INVALID_CLIENT", "Duplicate form parameter");
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
function isSafeRedirectUri(uri) {
|
|
130
|
-
try {
|
|
131
|
-
const parsed = new URL(uri);
|
|
132
|
-
if (parsed.protocol === "https:") {
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
135
|
-
return (parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname));
|
|
136
|
-
}
|
|
137
|
-
catch {
|
|
138
|
-
return false;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
function pkceChallenge(verifier) {
|
|
142
|
-
return sha256Base64Url(verifier);
|
|
143
|
-
}
|
|
144
|
-
function scopeList(scope) {
|
|
145
|
-
const valid = new Set(REMOTE_SCOPES);
|
|
146
|
-
const rawScopes = scope.split(/\s+/u).filter(Boolean);
|
|
147
|
-
if (rawScopes.some((entry) => !valid.has(entry))) {
|
|
148
|
-
throw safeError("INVALID_SCOPE", "Requested scope is not supported");
|
|
149
|
-
}
|
|
150
|
-
const scopes = parseScopes(scope);
|
|
151
|
-
return scopes.length > 0 ? scopes : ["hosts:read", "agents:read", "status:read", "logs:read"];
|
|
152
|
-
}
|
|
153
|
-
function sanitizeAgent(agent) {
|
|
154
|
-
return {
|
|
155
|
-
id: agent.id,
|
|
156
|
-
alias: agent.alias,
|
|
157
|
-
status: agent.status,
|
|
158
|
-
profile: agent.profile,
|
|
159
|
-
policy_version: agent.policyVersion,
|
|
160
|
-
host_metadata: agent.hostMetadata,
|
|
161
|
-
last_seen_at: agent.lastSeenAt,
|
|
162
|
-
created_at: agent.createdAt,
|
|
163
|
-
updated_at: agent.updatedAt,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
15
|
export class RemoteControlPlane {
|
|
167
16
|
config;
|
|
168
17
|
store;
|
|
@@ -173,10 +22,14 @@ export class RemoteControlPlane {
|
|
|
173
22
|
cleanupInterval;
|
|
174
23
|
jwtKeyPair;
|
|
175
24
|
controlPlaneKeyPair;
|
|
25
|
+
oauth;
|
|
26
|
+
agentHandler;
|
|
176
27
|
constructor(config = loadRemoteConfig()) {
|
|
177
28
|
this.config = config;
|
|
178
29
|
this.store = new RemoteStore(config.databaseUrl);
|
|
179
30
|
this.controlPlaneKeyPair = ensurePemKeyPair(config.controlPlaneSigningKeyPath);
|
|
31
|
+
this.oauth = new OAuthHandler(config, this.store, this.authorizeTransactions, () => this.requireJwtKeyPair(), (event) => this.audit(event));
|
|
32
|
+
this.agentHandler = new AgentWebSocketHandler(config, this.store, this.agentConnections, this.agentHelloNonces, this.pendingActions, (event) => this.audit(event));
|
|
180
33
|
this.cleanupInterval = setInterval(() => this.cleanupEphemeralState(), 60_000);
|
|
181
34
|
this.cleanupInterval.unref?.();
|
|
182
35
|
}
|
|
@@ -199,48 +52,36 @@ export class RemoteControlPlane {
|
|
|
199
52
|
this.store.close();
|
|
200
53
|
}
|
|
201
54
|
cleanupEphemeralState(now = Date.now()) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
this.authorizeTransactions.delete(transactionId);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
for (const [agentId, nonces] of this.agentHelloNonces.entries()) {
|
|
208
|
-
pruneNonceWindow(nonces, now);
|
|
209
|
-
if (nonces.size === 0) {
|
|
210
|
-
this.agentHelloNonces.delete(agentId);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
for (const live of this.agentConnections.values()) {
|
|
214
|
-
pruneNonceWindow(live.seenNonces, now);
|
|
215
|
-
}
|
|
55
|
+
this.oauth.cleanupExpired(now);
|
|
56
|
+
this.agentHandler.cleanupEphemeralState(now);
|
|
216
57
|
}
|
|
217
58
|
async handleHttp(req, res, pathname) {
|
|
218
59
|
if (pathname === "/.well-known/oauth-protected-resource" && req.method === "GET") {
|
|
219
|
-
jsonResponse(res, 200, this.protectedResourceMetadata(), addNoStore());
|
|
60
|
+
jsonResponse(res, 200, this.oauth.protectedResourceMetadata(), addNoStore());
|
|
220
61
|
return true;
|
|
221
62
|
}
|
|
222
63
|
if (pathname === "/.well-known/oauth-authorization-server" && req.method === "GET") {
|
|
223
|
-
jsonResponse(res, 200, this.authorizationServerMetadata(), addNoStore());
|
|
64
|
+
jsonResponse(res, 200, this.oauth.authorizationServerMetadata(), addNoStore());
|
|
224
65
|
return true;
|
|
225
66
|
}
|
|
226
67
|
if (pathname === "/oauth/register" && req.method === "POST") {
|
|
227
|
-
await this.handleRegister(req, res);
|
|
68
|
+
await this.oauth.handleRegister(req, res);
|
|
228
69
|
return true;
|
|
229
70
|
}
|
|
230
71
|
if (pathname === "/oauth/authorize" && req.method === "GET") {
|
|
231
|
-
await this.handleAuthorize(req, res);
|
|
72
|
+
await this.oauth.handleAuthorize(req, res);
|
|
232
73
|
return true;
|
|
233
74
|
}
|
|
234
75
|
if (pathname === "/oauth/callback/github" && req.method === "GET") {
|
|
235
|
-
await this.handleGitHubCallback(req, res);
|
|
76
|
+
await this.oauth.handleGitHubCallback(req, res);
|
|
236
77
|
return true;
|
|
237
78
|
}
|
|
238
79
|
if (pathname === "/oauth/token" && req.method === "POST") {
|
|
239
|
-
await this.handleToken(req, res);
|
|
80
|
+
await this.oauth.handleToken(req, res);
|
|
240
81
|
return true;
|
|
241
82
|
}
|
|
242
83
|
if (pathname === "/oauth/jwks.json" && req.method === "GET") {
|
|
243
|
-
await this.handleJwks(res);
|
|
84
|
+
await this.oauth.handleJwks(res);
|
|
244
85
|
return true;
|
|
245
86
|
}
|
|
246
87
|
if (pathname === "/readyz" && req.method === "GET") {
|
|
@@ -248,7 +89,7 @@ export class RemoteControlPlane {
|
|
|
248
89
|
ok: true,
|
|
249
90
|
service: "ssh-mcp-pro",
|
|
250
91
|
control_plane: true,
|
|
251
|
-
agents_online: this.
|
|
92
|
+
agents_online: this.agentHandler.connectedAgentCount,
|
|
252
93
|
});
|
|
253
94
|
return true;
|
|
254
95
|
}
|
|
@@ -263,281 +104,7 @@ export class RemoteControlPlane {
|
|
|
263
104
|
return false;
|
|
264
105
|
}
|
|
265
106
|
handleUpgrade(req, socket, head, pathname) {
|
|
266
|
-
|
|
267
|
-
return false;
|
|
268
|
-
}
|
|
269
|
-
const connection = acceptWebSocketUpgrade(req, socket, head);
|
|
270
|
-
connection.onText((message) => {
|
|
271
|
-
void this.handleAgentMessage(connection, message).catch(() => {
|
|
272
|
-
connection.sendJson({
|
|
273
|
-
type: "error",
|
|
274
|
-
code: "INTERNAL_ERROR",
|
|
275
|
-
message: "Agent message failed",
|
|
276
|
-
});
|
|
277
|
-
connection.close();
|
|
278
|
-
});
|
|
279
|
-
});
|
|
280
|
-
return true;
|
|
281
|
-
}
|
|
282
|
-
protectedResourceMetadata() {
|
|
283
|
-
return {
|
|
284
|
-
resource: this.config.mcpResourceUrl,
|
|
285
|
-
resource_name: "SshAutomator MCP",
|
|
286
|
-
authorization_servers: [this.config.publicBaseUrl],
|
|
287
|
-
bearer_methods_supported: ["header"],
|
|
288
|
-
scopes_supported: REMOTE_SCOPES,
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
authorizationServerMetadata() {
|
|
292
|
-
return {
|
|
293
|
-
issuer: this.config.publicBaseUrl,
|
|
294
|
-
authorization_endpoint: `${this.config.publicBaseUrl}/oauth/authorize`,
|
|
295
|
-
token_endpoint: `${this.config.publicBaseUrl}/oauth/token`,
|
|
296
|
-
registration_endpoint: `${this.config.publicBaseUrl}/oauth/register`,
|
|
297
|
-
jwks_uri: `${this.config.publicBaseUrl}/oauth/jwks.json`,
|
|
298
|
-
response_types_supported: ["code"],
|
|
299
|
-
grant_types_supported: ["authorization_code"],
|
|
300
|
-
code_challenge_methods_supported: ["S256"],
|
|
301
|
-
token_endpoint_auth_methods_supported: ["none"],
|
|
302
|
-
scopes_supported: REMOTE_SCOPES,
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
async handleRegister(req, res) {
|
|
306
|
-
const body = await readJson(req);
|
|
307
|
-
const redirectUris = asStringArray(body.redirect_uris);
|
|
308
|
-
if (redirectUris.length === 0 || redirectUris.some((uri) => !isSafeRedirectUri(uri))) {
|
|
309
|
-
throw safeError("INVALID_REDIRECT_URI", "redirect_uris must contain HTTPS URLs or localhost HTTP URLs");
|
|
310
|
-
}
|
|
311
|
-
if (this.store.countOAuthClients() >= this.config.maxOAuthClients) {
|
|
312
|
-
throw safeError("FORBIDDEN", "OAuth client registration limit reached", 429);
|
|
313
|
-
}
|
|
314
|
-
const now = nowIso();
|
|
315
|
-
const client = {
|
|
316
|
-
id: id("clirow"),
|
|
317
|
-
clientId: id("cli"),
|
|
318
|
-
clientName: asString(body.client_name) ?? "ChatGPT Connector",
|
|
319
|
-
redirectUris,
|
|
320
|
-
grantTypes: ["authorization_code"],
|
|
321
|
-
responseTypes: ["code"],
|
|
322
|
-
tokenEndpointAuthMethod: "none",
|
|
323
|
-
createdAt: now,
|
|
324
|
-
};
|
|
325
|
-
this.store.insertClient(client);
|
|
326
|
-
this.audit({
|
|
327
|
-
eventType: "oauth_client_registered",
|
|
328
|
-
severity: "info",
|
|
329
|
-
metadata: { client_id: client.clientId, redirect_uri_count: redirectUris.length },
|
|
330
|
-
});
|
|
331
|
-
jsonResponse(res, 201, {
|
|
332
|
-
client_id: client.clientId,
|
|
333
|
-
client_name: client.clientName,
|
|
334
|
-
redirect_uris: client.redirectUris,
|
|
335
|
-
grant_types: client.grantTypes,
|
|
336
|
-
response_types: client.responseTypes,
|
|
337
|
-
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
|
|
338
|
-
}, addNoStore());
|
|
339
|
-
}
|
|
340
|
-
async handleAuthorize(req, res) {
|
|
341
|
-
const url = new URL(req.url ?? "/oauth/authorize", this.config.publicBaseUrl);
|
|
342
|
-
const clientId = url.searchParams.get("client_id") ?? "";
|
|
343
|
-
const redirectUri = url.searchParams.get("redirect_uri") ?? "";
|
|
344
|
-
const responseType = url.searchParams.get("response_type") ?? "";
|
|
345
|
-
const codeChallenge = url.searchParams.get("code_challenge") ?? "";
|
|
346
|
-
const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "";
|
|
347
|
-
const state = url.searchParams.get("state") ?? "";
|
|
348
|
-
const resource = url.searchParams.get("resource") ?? this.config.mcpResourceUrl;
|
|
349
|
-
const scope = url.searchParams.get("scope") ?? "hosts:read agents:read status:read logs:read";
|
|
350
|
-
this.validateAuthorizeParams(clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod, resource, scope);
|
|
351
|
-
const pending = {
|
|
352
|
-
clientId,
|
|
353
|
-
redirectUri,
|
|
354
|
-
codeChallenge,
|
|
355
|
-
resource,
|
|
356
|
-
scope,
|
|
357
|
-
state,
|
|
358
|
-
expiresAt: Date.now() + this.config.authCodeTtlSeconds * 1000,
|
|
359
|
-
};
|
|
360
|
-
const testUser = this.testGitHubUser();
|
|
361
|
-
if (testUser) {
|
|
362
|
-
const user = this.upsertGitHubUser(testUser);
|
|
363
|
-
const code = this.issueAuthorizationCode(pending, user.id);
|
|
364
|
-
const destination = new URL(redirectUri);
|
|
365
|
-
destination.searchParams.set("code", code);
|
|
366
|
-
if (state) {
|
|
367
|
-
destination.searchParams.set("state", state);
|
|
368
|
-
}
|
|
369
|
-
redirect(res, destination.toString());
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
if (!this.config.githubClientId || !this.config.githubClientSecret) {
|
|
373
|
-
throw safeError("FORBIDDEN", "GitHub OAuth is not configured", 503);
|
|
374
|
-
}
|
|
375
|
-
const transactionId = id("code");
|
|
376
|
-
this.authorizeTransactions.set(transactionId, pending);
|
|
377
|
-
const githubUrl = new URL("https://github.com/login/oauth/authorize");
|
|
378
|
-
githubUrl.searchParams.set("client_id", this.config.githubClientId);
|
|
379
|
-
githubUrl.searchParams.set("redirect_uri", this.config.githubCallbackUrl);
|
|
380
|
-
githubUrl.searchParams.set("scope", "read:user");
|
|
381
|
-
githubUrl.searchParams.set("state", transactionId);
|
|
382
|
-
redirect(res, githubUrl.toString());
|
|
383
|
-
}
|
|
384
|
-
validateAuthorizeParams(clientId, redirectUri, responseType, codeChallenge, codeChallengeMethod, resource, scope) {
|
|
385
|
-
const client = this.store.getClient(clientId);
|
|
386
|
-
if (!client) {
|
|
387
|
-
throw safeError("INVALID_CLIENT", "Unknown client_id");
|
|
388
|
-
}
|
|
389
|
-
if (!client.redirectUris.includes(redirectUri) || !isSafeRedirectUri(redirectUri)) {
|
|
390
|
-
throw safeError("INVALID_REDIRECT_URI", "redirect_uri is not registered");
|
|
391
|
-
}
|
|
392
|
-
if (responseType !== "code") {
|
|
393
|
-
throw safeError("INVALID_CLIENT", "response_type must be code");
|
|
394
|
-
}
|
|
395
|
-
if (!codeChallenge || codeChallengeMethod !== "S256") {
|
|
396
|
-
throw safeError("PKCE_VALIDATION_FAILED", "PKCE S256 is required");
|
|
397
|
-
}
|
|
398
|
-
if (resource !== this.config.mcpResourceUrl) {
|
|
399
|
-
throw safeError("INVALID_TOKEN", "resource must match MCP resource URL");
|
|
400
|
-
}
|
|
401
|
-
scopeList(scope);
|
|
402
|
-
}
|
|
403
|
-
async handleGitHubCallback(req, res) {
|
|
404
|
-
const url = new URL(req.url ?? "/oauth/callback/github", this.config.publicBaseUrl);
|
|
405
|
-
const code = url.searchParams.get("code") ?? "";
|
|
406
|
-
const state = url.searchParams.get("state") ?? "";
|
|
407
|
-
const pending = this.authorizeTransactions.get(state);
|
|
408
|
-
this.authorizeTransactions.delete(state);
|
|
409
|
-
if (!code || !pending || pending.expiresAt < Date.now()) {
|
|
410
|
-
throw safeError("INVALID_TOKEN", "OAuth transaction is missing or expired");
|
|
411
|
-
}
|
|
412
|
-
const githubUser = await this.fetchGitHubUser(code);
|
|
413
|
-
const user = this.upsertGitHubUser(githubUser);
|
|
414
|
-
const authCode = this.issueAuthorizationCode(pending, user.id);
|
|
415
|
-
const destination = new URL(pending.redirectUri);
|
|
416
|
-
destination.searchParams.set("code", authCode);
|
|
417
|
-
if (pending.state) {
|
|
418
|
-
destination.searchParams.set("state", pending.state);
|
|
419
|
-
}
|
|
420
|
-
redirect(res, destination.toString());
|
|
421
|
-
}
|
|
422
|
-
async fetchGitHubUser(code) {
|
|
423
|
-
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
|
|
424
|
-
method: "POST",
|
|
425
|
-
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
|
426
|
-
body: JSON.stringify({
|
|
427
|
-
client_id: this.config.githubClientId,
|
|
428
|
-
client_secret: this.config.githubClientSecret,
|
|
429
|
-
code,
|
|
430
|
-
redirect_uri: this.config.githubCallbackUrl,
|
|
431
|
-
}),
|
|
432
|
-
});
|
|
433
|
-
const tokenPayload = (await tokenResponse.json());
|
|
434
|
-
const accessToken = asString(tokenPayload.access_token);
|
|
435
|
-
if (!accessToken) {
|
|
436
|
-
throw safeError("INVALID_TOKEN", "GitHub OAuth token exchange failed", 502);
|
|
437
|
-
}
|
|
438
|
-
const userResponse = await fetch("https://api.github.com/user", {
|
|
439
|
-
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" },
|
|
440
|
-
});
|
|
441
|
-
const userPayload = (await userResponse.json());
|
|
442
|
-
return { id: String(userPayload.id ?? ""), login: String(userPayload.login ?? "") };
|
|
443
|
-
}
|
|
444
|
-
testGitHubUser() {
|
|
445
|
-
const idValue = process.env.SSHAUTOMATOR_TEST_GITHUB_ID;
|
|
446
|
-
const login = process.env.SSHAUTOMATOR_TEST_GITHUB_LOGIN;
|
|
447
|
-
return idValue && login ? { id: idValue, login } : undefined;
|
|
448
|
-
}
|
|
449
|
-
upsertGitHubUser(githubUser) {
|
|
450
|
-
if (!this.isGitHubUserAllowed(githubUser)) {
|
|
451
|
-
throw safeError("FORBIDDEN", "GitHub user is not allowed");
|
|
452
|
-
}
|
|
453
|
-
const existing = this.store.getUserByGitHubId(githubUser.id);
|
|
454
|
-
const internalId = existing?.id ?? `github:${githubUser.id}`;
|
|
455
|
-
this.store.upsertUser({ ...githubUser, internalId, now: nowIso() });
|
|
456
|
-
this.audit({
|
|
457
|
-
userId: internalId,
|
|
458
|
-
eventType: "user_login",
|
|
459
|
-
severity: "info",
|
|
460
|
-
metadata: { github_id: githubUser.id, github_login: githubUser.login },
|
|
461
|
-
});
|
|
462
|
-
return { id: internalId, githubId: githubUser.id, githubLogin: githubUser.login };
|
|
463
|
-
}
|
|
464
|
-
isGitHubUserAllowed(user) {
|
|
465
|
-
return (this.config.allowAllUsers ||
|
|
466
|
-
this.config.allowedGitHubIds.includes(user.id) ||
|
|
467
|
-
this.config.allowedGitHubLogins.includes(user.login));
|
|
468
|
-
}
|
|
469
|
-
issueAuthorizationCode(pending, userId) {
|
|
470
|
-
const code = randomToken(32);
|
|
471
|
-
const now = nowIso();
|
|
472
|
-
const record = {
|
|
473
|
-
id: id("code"),
|
|
474
|
-
codeHash: hashSecret(code),
|
|
475
|
-
clientId: pending.clientId,
|
|
476
|
-
userId,
|
|
477
|
-
redirectUri: pending.redirectUri,
|
|
478
|
-
codeChallenge: pending.codeChallenge,
|
|
479
|
-
codeChallengeMethod: "S256",
|
|
480
|
-
resource: pending.resource,
|
|
481
|
-
scope: pending.scope,
|
|
482
|
-
expiresAt: new Date(Date.now() + this.config.authCodeTtlSeconds * 1000).toISOString(),
|
|
483
|
-
createdAt: now,
|
|
484
|
-
};
|
|
485
|
-
this.store.insertAuthorizationCode(record);
|
|
486
|
-
return code;
|
|
487
|
-
}
|
|
488
|
-
async handleToken(req, res) {
|
|
489
|
-
const body = await readJsonOrForm(req);
|
|
490
|
-
if (body.grant_type !== "authorization_code") {
|
|
491
|
-
throw safeError("INVALID_CLIENT", "grant_type must be authorization_code");
|
|
492
|
-
}
|
|
493
|
-
const clientId = body.client_id ?? "";
|
|
494
|
-
const client = this.store.getClient(clientId);
|
|
495
|
-
if (!client) {
|
|
496
|
-
throw safeError("INVALID_CLIENT", "Unknown client_id");
|
|
497
|
-
}
|
|
498
|
-
const code = body.code ?? "";
|
|
499
|
-
const redirectUri = body.redirect_uri ?? "";
|
|
500
|
-
const verifier = body.code_verifier ?? "";
|
|
501
|
-
const codeRecord = this.store.getAuthorizationCodeByHash(hashSecret(code));
|
|
502
|
-
if (codeRecord?.clientId !== clientId || codeRecord?.redirectUri !== redirectUri) {
|
|
503
|
-
throw safeError("INVALID_TOKEN", "Invalid authorization code");
|
|
504
|
-
}
|
|
505
|
-
if (codeRecord.usedAt || new Date(codeRecord.expiresAt).getTime() < Date.now()) {
|
|
506
|
-
throw safeError("INVALID_TOKEN", "Authorization code is expired or already used");
|
|
507
|
-
}
|
|
508
|
-
if (!verifier || pkceChallenge(verifier) !== codeRecord.codeChallenge) {
|
|
509
|
-
throw safeError("PKCE_VALIDATION_FAILED", "Invalid PKCE code_verifier");
|
|
510
|
-
}
|
|
511
|
-
const jwtKeyPair = this.requireJwtKeyPair();
|
|
512
|
-
const user = this.userFromId(codeRecord.userId);
|
|
513
|
-
const scopes = scopeList(codeRecord.scope);
|
|
514
|
-
try {
|
|
515
|
-
this.store.markAuthorizationCodeUsed(codeRecord.codeHash, nowIso());
|
|
516
|
-
}
|
|
517
|
-
catch {
|
|
518
|
-
throw safeError("INVALID_TOKEN", "Authorization code is expired or already used");
|
|
519
|
-
}
|
|
520
|
-
const token = await issueAccessToken(this.config, jwtKeyPair, user, scopes);
|
|
521
|
-
jsonResponse(res, 200, {
|
|
522
|
-
access_token: token.token,
|
|
523
|
-
token_type: "Bearer",
|
|
524
|
-
expires_in: this.config.accessTokenTtlSeconds,
|
|
525
|
-
scope: scopes.join(" "),
|
|
526
|
-
}, addNoStore());
|
|
527
|
-
}
|
|
528
|
-
userFromId(userId) {
|
|
529
|
-
if (userId.startsWith("github:")) {
|
|
530
|
-
const githubId = userId.slice("github:".length);
|
|
531
|
-
const user = this.store.getUserByGitHubId(githubId);
|
|
532
|
-
if (user) {
|
|
533
|
-
return user;
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
throw safeError("UNAUTHORIZED", "User no longer exists", 401);
|
|
537
|
-
}
|
|
538
|
-
async handleJwks(res) {
|
|
539
|
-
const jwtKeyPair = this.requireJwtKeyPair();
|
|
540
|
-
jsonResponse(res, 200, { keys: [await publicJwkFromPem(jwtKeyPair.publicKeyPem)] }, addNoStore());
|
|
107
|
+
return this.agentHandler.handleUpgrade(req, socket, head, pathname);
|
|
541
108
|
}
|
|
542
109
|
async handleMcp(req, res) {
|
|
543
110
|
if (req.method === "OPTIONS") {
|
|
@@ -706,207 +273,6 @@ export class RemoteControlPlane {
|
|
|
706
273
|
control_plane_public_key: this.controlPlaneKeyPair.publicKeyPem,
|
|
707
274
|
}, addNoStore());
|
|
708
275
|
}
|
|
709
|
-
async handleAgentMessage(connection, message) {
|
|
710
|
-
const payload = JSON.parse(message);
|
|
711
|
-
if (!isRecord(payload)) {
|
|
712
|
-
connection.sendJson({ type: "error", code: "INTERNAL_ERROR", message: "Invalid message" });
|
|
713
|
-
connection.close();
|
|
714
|
-
return;
|
|
715
|
-
}
|
|
716
|
-
if (payload.type === "agent.hello") {
|
|
717
|
-
await this.handleAgentHello(connection, parseAgentHelloEnvelope(payload));
|
|
718
|
-
return;
|
|
719
|
-
}
|
|
720
|
-
if (payload.type === "action.result") {
|
|
721
|
-
await this.handleActionResult(connection, parseActionResultEnvelope(payload));
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
|
-
connection.sendJson({ type: "error", code: "INTERNAL_ERROR", message: "Unknown message type" });
|
|
725
|
-
connection.close();
|
|
726
|
-
}
|
|
727
|
-
async handleAgentHello(connection, hello) {
|
|
728
|
-
const agent = this.store.getAgent(hello.agent_id);
|
|
729
|
-
if (!agent || agent.status === "revoked" || !agent.publicKey) {
|
|
730
|
-
connection.sendJson({
|
|
731
|
-
type: "error",
|
|
732
|
-
code: "AGENT_NOT_FOUND",
|
|
733
|
-
message: "Agent is not enrolled",
|
|
734
|
-
});
|
|
735
|
-
connection.close();
|
|
736
|
-
return;
|
|
737
|
-
}
|
|
738
|
-
if (!verifyEnvelope(hello, agent.publicKey)) {
|
|
739
|
-
this.audit({
|
|
740
|
-
userId: agent.userId,
|
|
741
|
-
agentId: agent.id,
|
|
742
|
-
eventType: "agent_signature_invalid",
|
|
743
|
-
severity: "warn",
|
|
744
|
-
metadata: { message_type: "agent.hello" },
|
|
745
|
-
});
|
|
746
|
-
connection.sendJson({
|
|
747
|
-
type: "error",
|
|
748
|
-
code: "SIGNATURE_INVALID",
|
|
749
|
-
message: "Agent signature is invalid",
|
|
750
|
-
});
|
|
751
|
-
connection.close();
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
const timestampAgeMs = Math.abs(Date.now() - new Date(hello.timestamp).getTime());
|
|
755
|
-
if (timestampAgeMs > 300_000) {
|
|
756
|
-
this.audit({
|
|
757
|
-
userId: agent.userId,
|
|
758
|
-
agentId: agent.id,
|
|
759
|
-
eventType: "agent_hello_expired",
|
|
760
|
-
severity: "warn",
|
|
761
|
-
metadata: {},
|
|
762
|
-
});
|
|
763
|
-
connection.sendJson({
|
|
764
|
-
type: "error",
|
|
765
|
-
code: "ACTION_EXPIRED",
|
|
766
|
-
message: "Agent hello timestamp is stale",
|
|
767
|
-
});
|
|
768
|
-
connection.close();
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
const now = Date.now();
|
|
772
|
-
this.cleanupEphemeralState(now);
|
|
773
|
-
const existingConnection = this.agentConnections.get(agent.id);
|
|
774
|
-
if (existingConnection?.connection === connection) {
|
|
775
|
-
this.audit({
|
|
776
|
-
userId: agent.userId,
|
|
777
|
-
agentId: agent.id,
|
|
778
|
-
eventType: "agent_duplicate_hello_rejected",
|
|
779
|
-
severity: "warn",
|
|
780
|
-
metadata: {},
|
|
781
|
-
});
|
|
782
|
-
connection.sendJson({
|
|
783
|
-
type: "error",
|
|
784
|
-
code: "ACTION_REPLAY_DETECTED",
|
|
785
|
-
message: "Agent hello was already processed on this connection",
|
|
786
|
-
});
|
|
787
|
-
connection.close();
|
|
788
|
-
return;
|
|
789
|
-
}
|
|
790
|
-
const helloNonces = this.agentHelloNonces.get(agent.id) ?? new Map();
|
|
791
|
-
if (helloNonces.has(hello.nonce)) {
|
|
792
|
-
this.audit({
|
|
793
|
-
userId: agent.userId,
|
|
794
|
-
agentId: agent.id,
|
|
795
|
-
eventType: "agent_hello_replay_detected",
|
|
796
|
-
severity: "warn",
|
|
797
|
-
metadata: {},
|
|
798
|
-
});
|
|
799
|
-
connection.sendJson({
|
|
800
|
-
type: "error",
|
|
801
|
-
code: "ACTION_REPLAY_DETECTED",
|
|
802
|
-
message: "Agent hello nonce was already used",
|
|
803
|
-
});
|
|
804
|
-
connection.close();
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
helloNonces.set(hello.nonce, now + AGENT_NONCE_TTL_MS);
|
|
808
|
-
this.agentHelloNonces.set(agent.id, helloNonces);
|
|
809
|
-
if (existingConnection) {
|
|
810
|
-
existingConnection.connection.close();
|
|
811
|
-
}
|
|
812
|
-
const seenNonces = new Map();
|
|
813
|
-
rememberNonce(seenNonces, hello.nonce, now);
|
|
814
|
-
this.agentConnections.set(agent.id, { agent, connection, seenNonces });
|
|
815
|
-
const online = {
|
|
816
|
-
...agent,
|
|
817
|
-
status: "online",
|
|
818
|
-
lastSeenAt: nowIso(),
|
|
819
|
-
hostMetadata: hello.host,
|
|
820
|
-
updatedAt: nowIso(),
|
|
821
|
-
};
|
|
822
|
-
this.store.updateAgent(online);
|
|
823
|
-
connection.onClose(() => {
|
|
824
|
-
const live = this.agentConnections.get(agent.id);
|
|
825
|
-
if (live?.connection !== connection) {
|
|
826
|
-
return;
|
|
827
|
-
}
|
|
828
|
-
this.agentConnections.delete(agent.id);
|
|
829
|
-
const latest = this.store.getAgent(agent.id);
|
|
830
|
-
if (latest && latest.status !== "revoked") {
|
|
831
|
-
this.store.updateAgent({ ...latest, status: "offline", updatedAt: nowIso() });
|
|
832
|
-
}
|
|
833
|
-
this.audit({
|
|
834
|
-
userId: agent.userId,
|
|
835
|
-
agentId: agent.id,
|
|
836
|
-
eventType: "agent_disconnected",
|
|
837
|
-
severity: "info",
|
|
838
|
-
metadata: {},
|
|
839
|
-
});
|
|
840
|
-
});
|
|
841
|
-
this.audit({
|
|
842
|
-
userId: agent.userId,
|
|
843
|
-
agentId: agent.id,
|
|
844
|
-
eventType: "agent_connected",
|
|
845
|
-
severity: "info",
|
|
846
|
-
metadata: { agent_version: hello.agent_version, host: hello.host.hostname },
|
|
847
|
-
});
|
|
848
|
-
connection.sendJson({ type: "agent.ready", agent_id: agent.id, policy: agent.policy });
|
|
849
|
-
}
|
|
850
|
-
async handleActionResult(connection, result) {
|
|
851
|
-
const pending = this.pendingActions.get(result.action_id);
|
|
852
|
-
if (!pending) {
|
|
853
|
-
return;
|
|
854
|
-
}
|
|
855
|
-
const agent = this.store.getAgent(result.agent_id);
|
|
856
|
-
if (!agent?.publicKey ||
|
|
857
|
-
!verifyEnvelope(result, agent.publicKey)) {
|
|
858
|
-
clearTimeout(pending.timeout);
|
|
859
|
-
this.pendingActions.delete(result.action_id);
|
|
860
|
-
this.audit({
|
|
861
|
-
userId: pending.action.userId,
|
|
862
|
-
agentId: pending.action.agentId,
|
|
863
|
-
actionId: pending.action.id,
|
|
864
|
-
eventType: "agent_result_signature_invalid",
|
|
865
|
-
severity: "warn",
|
|
866
|
-
metadata: {},
|
|
867
|
-
});
|
|
868
|
-
pending.reject(new Error("Agent result signature is invalid"));
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
871
|
-
const live = this.agentConnections.get(result.agent_id);
|
|
872
|
-
if (pending.action.agentId !== result.agent_id || live?.connection !== connection) {
|
|
873
|
-
clearTimeout(pending.timeout);
|
|
874
|
-
this.pendingActions.delete(result.action_id);
|
|
875
|
-
this.audit({
|
|
876
|
-
userId: pending.action.userId,
|
|
877
|
-
agentId: pending.action.agentId,
|
|
878
|
-
actionId: pending.action.id,
|
|
879
|
-
eventType: "agent_result_connection_invalid",
|
|
880
|
-
severity: "warn",
|
|
881
|
-
metadata: {},
|
|
882
|
-
});
|
|
883
|
-
pending.reject(Object.assign(new Error("Agent result came from an unexpected connection"), {
|
|
884
|
-
code: "SIGNATURE_INVALID",
|
|
885
|
-
}));
|
|
886
|
-
return;
|
|
887
|
-
}
|
|
888
|
-
const now = Date.now();
|
|
889
|
-
if (hasSeenNonce(live.seenNonces, result.nonce, now)) {
|
|
890
|
-
clearTimeout(pending.timeout);
|
|
891
|
-
this.pendingActions.delete(result.action_id);
|
|
892
|
-
this.audit({
|
|
893
|
-
userId: pending.action.userId,
|
|
894
|
-
agentId: pending.action.agentId,
|
|
895
|
-
actionId: pending.action.id,
|
|
896
|
-
eventType: "agent_result_replay_detected",
|
|
897
|
-
severity: "warn",
|
|
898
|
-
metadata: {},
|
|
899
|
-
});
|
|
900
|
-
pending.reject(Object.assign(new Error("Agent result nonce was already used"), {
|
|
901
|
-
code: "ACTION_REPLAY_DETECTED",
|
|
902
|
-
}));
|
|
903
|
-
return;
|
|
904
|
-
}
|
|
905
|
-
rememberNonce(live.seenNonces, result.nonce, now);
|
|
906
|
-
clearTimeout(pending.timeout);
|
|
907
|
-
this.pendingActions.delete(result.action_id);
|
|
908
|
-
pending.resolve(result);
|
|
909
|
-
}
|
|
910
276
|
async authenticate(req) {
|
|
911
277
|
try {
|
|
912
278
|
return await verifyRemoteAccessToken(req.headers.authorization, this.config, this.requireJwtKeyPair());
|