@runuai/host 0.4.2 → 0.4.3

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.
@@ -0,0 +1,522 @@
1
+ /**
2
+ * User-authed MCP connections (ADR-057) — the host side of the generic
3
+ * handshake. Everything secret happens here: OAuth discovery (RFC 9728
4
+ * protected-resource metadata → RFC 8414 AS metadata), dynamic client
5
+ * registration (RFC 7591), PKCE, the code exchange, token refresh, and the
6
+ * sealed secret store. The cloud only ever sees non-secret status/URLs.
7
+ *
8
+ * Auth kinds:
9
+ * - "oauth" — discovered from a 401 + WWW-Authenticate chain.
10
+ * - "token" — a static header the user pasted (write-only over the bridge).
11
+ * - "none" — the URL answered an unauthenticated initialize.
12
+ */
13
+
14
+ import { createHash, randomBytes } from "node:crypto";
15
+ import { eq } from "drizzle-orm";
16
+
17
+ import { getDb, schema } from "./db";
18
+ import { sealAesGcm, openAesGcm } from "./secrets";
19
+ import type { McpOp } from "../src/protocol";
20
+
21
+ const FETCH_TIMEOUT_MS = 10_000;
22
+ // Refresh an access token this long before its recorded expiry.
23
+ const REFRESH_LEAD_MS = 60_000;
24
+
25
+ export interface McpAck {
26
+ status: "connected" | "auth_required" | "disconnected";
27
+ authorizeUrl?: string;
28
+ scopes?: string[];
29
+ }
30
+
31
+ interface OauthSecret {
32
+ accessToken: string;
33
+ refreshToken?: string;
34
+ expiresAt?: number; // epoch ms; absent = treat as non-expiring
35
+ }
36
+
37
+ interface HeaderSecret {
38
+ headerName: string;
39
+ headerValue: string;
40
+ }
41
+
42
+ // --- sealed single-column packing (same format as host-env.ts) --------------
43
+
44
+ function pack(value: string): string {
45
+ const sealed = sealAesGcm(value);
46
+ return `${sealed.ct.toString("base64")}.${sealed.nonce.toString("base64")}`;
47
+ }
48
+
49
+ function unpack(enc: string): string {
50
+ const dot = enc.indexOf(".");
51
+ const ct = Buffer.from(enc.slice(0, dot), "base64");
52
+ const nonce = Buffer.from(enc.slice(dot + 1), "base64");
53
+ return openAesGcm(ct, nonce);
54
+ }
55
+
56
+ // --- fetch helpers -----------------------------------------------------------
57
+
58
+ async function timedFetch(url: string, init?: RequestInit): Promise<Response> {
59
+ return fetch(url, {
60
+ ...init,
61
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
62
+ redirect: "follow",
63
+ });
64
+ }
65
+
66
+ async function fetchJson(url: string): Promise<Record<string, unknown> | null> {
67
+ try {
68
+ const res = await timedFetch(url, { headers: { accept: "application/json" } });
69
+ if (!res.ok) return null;
70
+ return (await res.json()) as Record<string, unknown>;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /** POST an MCP `initialize` (streamable HTTP). Returns the raw Response. */
77
+ async function mcpInitialize(
78
+ url: string,
79
+ header?: HeaderSecret | null,
80
+ ): Promise<Response> {
81
+ return timedFetch(url, {
82
+ method: "POST",
83
+ headers: {
84
+ "content-type": "application/json",
85
+ accept: "application/json, text/event-stream",
86
+ ...(header ? { [header.headerName]: header.headerValue } : {}),
87
+ },
88
+ body: JSON.stringify({
89
+ jsonrpc: "2.0",
90
+ id: 0,
91
+ method: "initialize",
92
+ params: {
93
+ protocolVersion: "2025-06-18",
94
+ capabilities: {},
95
+ clientInfo: { name: "uai-host", version: "1.0" },
96
+ },
97
+ }),
98
+ });
99
+ }
100
+
101
+ // --- OAuth discovery (RFC 9728 → RFC 8414) -----------------------------------
102
+
103
+ /** Candidate well-known URLs, path-aware per spec, root fallback last. */
104
+ function wellKnownCandidates(base: string, suffix: string): string[] {
105
+ const u = new URL(base);
106
+ const path = u.pathname.replace(/\/$/, "");
107
+ const out: string[] = [];
108
+ if (path && path !== "") {
109
+ out.push(`${u.origin}/.well-known/${suffix}${path}`);
110
+ }
111
+ out.push(`${u.origin}/.well-known/${suffix}`);
112
+ return out;
113
+ }
114
+
115
+ /** Parse `resource_metadata="…"` out of a WWW-Authenticate header. */
116
+ function resourceMetadataUrl(wwwAuth: string | null): string | null {
117
+ if (!wwwAuth) return null;
118
+ const m = /resource_metadata="([^"]+)"/i.exec(wwwAuth);
119
+ return m?.[1] ?? null;
120
+ }
121
+
122
+ interface Discovered {
123
+ authorizationEndpoint: string;
124
+ tokenEndpoint: string;
125
+ registrationEndpoint?: string;
126
+ scopesSupported?: string[];
127
+ }
128
+
129
+ /**
130
+ * From the MCP URL + the 401's WWW-Authenticate, find the authorization
131
+ * server's metadata. Tolerates servers that skip RFC 9728 (falls back to
132
+ * treating the MCP origin as its own authorization server).
133
+ */
134
+ async function discoverAuth(
135
+ mcpUrl: string,
136
+ wwwAuth: string | null,
137
+ ): Promise<Discovered> {
138
+ // 1. Protected-resource metadata → authorization_servers[0].
139
+ const prmCandidates = [
140
+ ...(resourceMetadataUrl(wwwAuth) ? [resourceMetadataUrl(wwwAuth)!] : []),
141
+ ...wellKnownCandidates(mcpUrl, "oauth-protected-resource"),
142
+ ];
143
+ let asBase: string | null = null;
144
+ let resourceScopes: string[] | undefined;
145
+ for (const url of prmCandidates) {
146
+ const prm = await fetchJson(url);
147
+ const servers = prm?.authorization_servers;
148
+ if (Array.isArray(servers) && typeof servers[0] === "string") {
149
+ asBase = servers[0];
150
+ if (Array.isArray(prm?.scopes_supported)) {
151
+ resourceScopes = prm.scopes_supported as string[];
152
+ }
153
+ break;
154
+ }
155
+ }
156
+ // Servers that skip RFC 9728 often ARE their own AS.
157
+ asBase ??= new URL(mcpUrl).origin;
158
+
159
+ // 2. AS metadata (RFC 8414, then OIDC discovery).
160
+ const asCandidates = [
161
+ ...wellKnownCandidates(asBase, "oauth-authorization-server"),
162
+ ...wellKnownCandidates(asBase, "openid-configuration"),
163
+ ];
164
+ for (const url of asCandidates) {
165
+ const meta = await fetchJson(url);
166
+ if (
167
+ typeof meta?.authorization_endpoint === "string" &&
168
+ typeof meta?.token_endpoint === "string"
169
+ ) {
170
+ return {
171
+ authorizationEndpoint: meta.authorization_endpoint,
172
+ tokenEndpoint: meta.token_endpoint,
173
+ registrationEndpoint:
174
+ typeof meta.registration_endpoint === "string"
175
+ ? meta.registration_endpoint
176
+ : undefined,
177
+ scopesSupported:
178
+ resourceScopes ??
179
+ (Array.isArray(meta.scopes_supported)
180
+ ? (meta.scopes_supported as string[])
181
+ : undefined),
182
+ };
183
+ }
184
+ }
185
+ throw new Error(
186
+ "the server requires auth but exposes no OAuth metadata (" +
187
+ "no /.well-known/oauth-protected-resource or …/oauth-authorization-server)",
188
+ );
189
+ }
190
+
191
+ /** RFC 7591 dynamic client registration. Returns client id (+secret). */
192
+ async function registerClient(
193
+ registrationEndpoint: string,
194
+ redirectUri: string,
195
+ ): Promise<{ clientId: string; clientSecret?: string }> {
196
+ const res = await timedFetch(registrationEndpoint, {
197
+ method: "POST",
198
+ headers: { "content-type": "application/json" },
199
+ body: JSON.stringify({
200
+ client_name: "Uai",
201
+ redirect_uris: [redirectUri],
202
+ grant_types: ["authorization_code", "refresh_token"],
203
+ response_types: ["code"],
204
+ token_endpoint_auth_method: "none",
205
+ }),
206
+ });
207
+ if (!res.ok) {
208
+ throw new Error(`dynamic client registration failed (${res.status})`);
209
+ }
210
+ const json = (await res.json()) as Record<string, unknown>;
211
+ if (typeof json.client_id !== "string") {
212
+ throw new Error("registration response carried no client_id");
213
+ }
214
+ return {
215
+ clientId: json.client_id,
216
+ clientSecret:
217
+ typeof json.client_secret === "string" ? json.client_secret : undefined,
218
+ };
219
+ }
220
+
221
+ function b64url(buf: Buffer): string {
222
+ return buf.toString("base64url");
223
+ }
224
+
225
+ // --- op handlers --------------------------------------------------------------
226
+
227
+ export async function handleMcpOp(op: McpOp): Promise<McpAck> {
228
+ switch (op.kind) {
229
+ case "probe":
230
+ return probe(op);
231
+ case "oauth.complete":
232
+ return oauthComplete(op.connectionId, op.code);
233
+ case "disconnect": {
234
+ getDb()
235
+ .delete(schema.mcpConnections)
236
+ .where(eq(schema.mcpConnections.id, op.connectionId))
237
+ .run();
238
+ return { status: "disconnected" };
239
+ }
240
+ }
241
+ }
242
+
243
+ async function probe(op: Extract<McpOp, { kind: "probe" }>): Promise<McpAck> {
244
+ const db = getDb();
245
+ const now = Date.now();
246
+
247
+ // Static-header connection: verify the header works, seal, done.
248
+ if (op.headerName && op.headerValue) {
249
+ const header = { headerName: op.headerName, headerValue: op.headerValue };
250
+ const res = await mcpInitialize(op.url, header);
251
+ if (res.status === 401 || res.status === 403) {
252
+ throw new Error(`the server rejected the token (${res.status})`);
253
+ }
254
+ if (!res.ok) throw new Error(`unexpected response (${res.status})`);
255
+ db.insert(schema.mcpConnections)
256
+ .values({
257
+ id: op.connectionId,
258
+ userId: op.userId,
259
+ url: op.url,
260
+ authKind: "token",
261
+ secretEnc: pack(JSON.stringify(header)),
262
+ status: "connected",
263
+ updatedAt: now,
264
+ })
265
+ .onConflictDoUpdate({
266
+ target: schema.mcpConnections.id,
267
+ set: {
268
+ url: op.url,
269
+ authKind: "token",
270
+ secretEnc: pack(JSON.stringify(header)),
271
+ status: "connected",
272
+ updatedAt: now,
273
+ },
274
+ })
275
+ .run();
276
+ return { status: "connected" };
277
+ }
278
+
279
+ // Bare probe.
280
+ const res = await mcpInitialize(op.url);
281
+ if (res.ok) {
282
+ db.insert(schema.mcpConnections)
283
+ .values({
284
+ id: op.connectionId,
285
+ userId: op.userId,
286
+ url: op.url,
287
+ authKind: "none",
288
+ status: "connected",
289
+ updatedAt: now,
290
+ })
291
+ .onConflictDoUpdate({
292
+ target: schema.mcpConnections.id,
293
+ set: { url: op.url, authKind: "none", status: "connected", updatedAt: now },
294
+ })
295
+ .run();
296
+ return { status: "connected" };
297
+ }
298
+ if (res.status !== 401 && res.status !== 403) {
299
+ throw new Error(
300
+ `not an MCP server? initialize returned ${res.status} ${res.statusText}`,
301
+ );
302
+ }
303
+ if (!op.state || !op.redirectUri) {
304
+ throw new Error("server requires OAuth but no state/redirectUri provided");
305
+ }
306
+
307
+ // OAuth: discovery → client → PKCE → authorize URL.
308
+ const found = await discoverAuth(op.url, res.headers.get("www-authenticate"));
309
+ let clientId = op.clientId;
310
+ let clientSecret = op.clientSecret;
311
+ if (!clientId) {
312
+ if (!found.registrationEndpoint) {
313
+ throw new Error(
314
+ "the authorization server does not support dynamic client " +
315
+ "registration — enter a client id/secret manually",
316
+ );
317
+ }
318
+ const reg = await registerClient(found.registrationEndpoint, op.redirectUri);
319
+ clientId = reg.clientId;
320
+ clientSecret = reg.clientSecret;
321
+ }
322
+
323
+ const verifier = b64url(randomBytes(32));
324
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
325
+ const authorize = new URL(found.authorizationEndpoint);
326
+ authorize.searchParams.set("response_type", "code");
327
+ authorize.searchParams.set("client_id", clientId);
328
+ authorize.searchParams.set("redirect_uri", op.redirectUri);
329
+ authorize.searchParams.set("state", op.state);
330
+ authorize.searchParams.set("code_challenge", challenge);
331
+ authorize.searchParams.set("code_challenge_method", "S256");
332
+ // RFC 8707 resource indicator — REQUIRED by the MCP auth spec.
333
+ authorize.searchParams.set("resource", op.url);
334
+ if (found.scopesSupported?.length) {
335
+ authorize.searchParams.set("scope", found.scopesSupported.join(" "));
336
+ }
337
+
338
+ const fields = {
339
+ userId: op.userId,
340
+ url: op.url,
341
+ authKind: "oauth",
342
+ tokenEndpoint: found.tokenEndpoint,
343
+ redirectUri: op.redirectUri,
344
+ clientId,
345
+ clientSecretEnc: clientSecret ? pack(clientSecret) : null,
346
+ pkceVerifierEnc: pack(verifier),
347
+ secretEnc: null,
348
+ status: "pending",
349
+ scopes: found.scopesSupported?.join(" ") ?? null,
350
+ updatedAt: now,
351
+ };
352
+ db.insert(schema.mcpConnections)
353
+ .values({ id: op.connectionId, ...fields })
354
+ .onConflictDoUpdate({ target: schema.mcpConnections.id, set: fields })
355
+ .run();
356
+ return { status: "auth_required", authorizeUrl: authorize.toString() };
357
+ }
358
+
359
+ async function oauthComplete(connectionId: string, code: string): Promise<McpAck> {
360
+ const db = getDb();
361
+ const row = db
362
+ .select()
363
+ .from(schema.mcpConnections)
364
+ .where(eq(schema.mcpConnections.id, connectionId))
365
+ .get();
366
+ if (!row) throw new Error("unknown connection — probe first");
367
+ if (!row.tokenEndpoint || !row.clientId || !row.pkceVerifierEnc || !row.redirectUri) {
368
+ throw new Error("connection has no pending OAuth flow");
369
+ }
370
+
371
+ const body = new URLSearchParams({
372
+ grant_type: "authorization_code",
373
+ code,
374
+ redirect_uri: row.redirectUri,
375
+ client_id: row.clientId,
376
+ code_verifier: unpack(row.pkceVerifierEnc),
377
+ // RFC 8707, mirrored from the authorize request.
378
+ resource: row.url,
379
+ });
380
+ const headers: Record<string, string> = {
381
+ "content-type": "application/x-www-form-urlencoded",
382
+ accept: "application/json",
383
+ };
384
+ if (row.clientSecretEnc) {
385
+ headers.authorization =
386
+ "Basic " +
387
+ Buffer.from(`${row.clientId}:${unpack(row.clientSecretEnc)}`).toString(
388
+ "base64",
389
+ );
390
+ }
391
+ const res = await timedFetch(row.tokenEndpoint, { method: "POST", headers, body });
392
+ const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
393
+ if (!res.ok || typeof json.access_token !== "string") {
394
+ const detail =
395
+ typeof json.error === "string" ? json.error : `status ${res.status}`;
396
+ throw new Error(`token exchange failed (${detail})`);
397
+ }
398
+
399
+ const secret: OauthSecret = {
400
+ accessToken: json.access_token,
401
+ refreshToken:
402
+ typeof json.refresh_token === "string" ? json.refresh_token : undefined,
403
+ expiresAt:
404
+ typeof json.expires_in === "number"
405
+ ? Date.now() + json.expires_in * 1000
406
+ : undefined,
407
+ };
408
+ const scopes =
409
+ typeof json.scope === "string" && json.scope.length > 0
410
+ ? json.scope
411
+ : row.scopes;
412
+ db.update(schema.mcpConnections)
413
+ .set({
414
+ secretEnc: pack(JSON.stringify(secret)),
415
+ pkceVerifierEnc: null,
416
+ status: "connected",
417
+ scopes,
418
+ updatedAt: Date.now(),
419
+ })
420
+ .where(eq(schema.mcpConnections.id, connectionId))
421
+ .run();
422
+ return {
423
+ status: "connected",
424
+ scopes: scopes ? scopes.split(" ").filter(Boolean) : undefined,
425
+ };
426
+ }
427
+
428
+ // --- gateway support ----------------------------------------------------------
429
+
430
+ /** A connected row for `id`, or null. */
431
+ export function getConnection(id: string): schema.McpConnection | null {
432
+ return (
433
+ getDb()
434
+ .select()
435
+ .from(schema.mcpConnections)
436
+ .where(eq(schema.mcpConnections.id, id))
437
+ .get() ?? null
438
+ );
439
+ }
440
+
441
+ /** All CONNECTED connections owned by `userId` (task-up + gateway ACL). */
442
+ export function listConnectionsForUser(userId: string): schema.McpConnection[] {
443
+ return getDb()
444
+ .select()
445
+ .from(schema.mcpConnections)
446
+ .where(eq(schema.mcpConnections.userId, userId))
447
+ .all()
448
+ .filter((r) => r.status === "connected");
449
+ }
450
+
451
+ /**
452
+ * The Authorization (or custom) header for a connection, refreshing an
453
+ * expired OAuth access token first (rotating the stored refresh token when
454
+ * the server issues a new one). Null when the connection needs no header.
455
+ */
456
+ export async function authHeaderFor(
457
+ row: schema.McpConnection,
458
+ forceRefresh = false,
459
+ ): Promise<{ name: string; value: string } | null> {
460
+ if (row.authKind === "none") return null;
461
+ if (!row.secretEnc) throw new Error("connection has no stored secret");
462
+ if (row.authKind === "token") {
463
+ const h = JSON.parse(unpack(row.secretEnc)) as HeaderSecret;
464
+ return { name: h.headerName, value: h.headerValue };
465
+ }
466
+ let secret = JSON.parse(unpack(row.secretEnc)) as OauthSecret;
467
+ if (
468
+ forceRefresh ||
469
+ (secret.expiresAt && secret.expiresAt - REFRESH_LEAD_MS < Date.now())
470
+ ) {
471
+ secret = await refreshTokens(row, secret);
472
+ }
473
+ return { name: "authorization", value: `Bearer ${secret.accessToken}` };
474
+ }
475
+
476
+ async function refreshTokens(
477
+ row: schema.McpConnection,
478
+ secret: OauthSecret,
479
+ ): Promise<OauthSecret> {
480
+ if (!secret.refreshToken || !row.tokenEndpoint || !row.clientId) {
481
+ throw new Error("access token expired and no refresh token is stored");
482
+ }
483
+ const body = new URLSearchParams({
484
+ grant_type: "refresh_token",
485
+ refresh_token: secret.refreshToken,
486
+ client_id: row.clientId,
487
+ resource: row.url,
488
+ });
489
+ const headers: Record<string, string> = {
490
+ "content-type": "application/x-www-form-urlencoded",
491
+ accept: "application/json",
492
+ };
493
+ if (row.clientSecretEnc) {
494
+ headers.authorization =
495
+ "Basic " +
496
+ Buffer.from(`${row.clientId}:${unpack(row.clientSecretEnc)}`).toString(
497
+ "base64",
498
+ );
499
+ }
500
+ const res = await timedFetch(row.tokenEndpoint, { method: "POST", headers, body });
501
+ const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
502
+ if (!res.ok || typeof json.access_token !== "string") {
503
+ throw new Error(`token refresh failed (status ${res.status})`);
504
+ }
505
+ const next: OauthSecret = {
506
+ accessToken: json.access_token,
507
+ refreshToken:
508
+ typeof json.refresh_token === "string"
509
+ ? json.refresh_token
510
+ : secret.refreshToken,
511
+ expiresAt:
512
+ typeof json.expires_in === "number"
513
+ ? Date.now() + json.expires_in * 1000
514
+ : undefined,
515
+ };
516
+ getDb()
517
+ .update(schema.mcpConnections)
518
+ .set({ secretEnc: pack(JSON.stringify(next)), updatedAt: Date.now() })
519
+ .where(eq(schema.mcpConnections.id, row.id))
520
+ .run();
521
+ return next;
522
+ }