@harness-fe/mcp-server 4.0.0-next.2 → 4.0.0-next.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/dist/bin.d.ts +2 -0
- package/dist/bin.js +15 -0
- package/dist/daemon.d.ts +3 -3
- package/dist/daemon.js +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +65 -19
- package/dist/mcpHttp.d.ts +2 -2
- package/dist/mcpHttp.js +88 -18
- package/package.json +5 -7
- package/src/bin.ts +19 -0
- package/src/daemon.ts +3 -3
- package/src/experimental.test.ts +2 -2
- package/src/index.ts +4 -4
- package/src/mcp.ts +67 -23
- package/src/mcpHttp.test.ts +52 -3
- package/src/mcpHttp.ts +102 -23
- package/src/mcpLayer.e2e.test.ts +2 -2
- package/src/newCapabilities.e2e.test.ts +3 -3
- package/dist/auth.d.ts +0 -53
- package/dist/auth.js +0 -212
- package/dist/bridge.d.ts +0 -323
- package/dist/bridge.js +0 -1618
- package/dist/cli.d.ts +0 -18
- package/dist/cli.js +0 -293
- package/dist/dashboardApi.d.ts +0 -40
- package/dist/dashboardApi.js +0 -142
- package/dist/dashboardSpa.d.ts +0 -18
- package/dist/dashboardSpa.js +0 -180
- package/dist/dashboardUrl.d.ts +0 -13
- package/dist/dashboardUrl.js +0 -18
- package/dist/eventsHandler.d.ts +0 -24
- package/dist/eventsHandler.js +0 -114
- package/dist/identity.d.ts +0 -90
- package/dist/identity.js +0 -123
- package/dist/openBrowser.d.ts +0 -33
- package/dist/openBrowser.js +0 -63
- package/dist/remoteBridge.d.ts +0 -61
- package/dist/remoteBridge.js +0 -307
- package/dist/replayCreate.d.ts +0 -36
- package/dist/replayCreate.js +0 -156
- package/dist/replayViewer.d.ts +0 -20
- package/dist/replayViewer.js +0 -168
- package/dist/sessionRouter.d.ts +0 -45
- package/dist/sessionRouter.js +0 -88
- package/dist/store/JsonMemoryStore.d.ts +0 -52
- package/dist/store/JsonMemoryStore.js +0 -119
- package/dist/store/JsonTaskStore.d.ts +0 -21
- package/dist/store/JsonTaskStore.js +0 -53
- package/dist/store/JsonlStore.d.ts +0 -128
- package/dist/store/JsonlStore.js +0 -1172
- package/dist/store/MemoryEventStore.d.ts +0 -47
- package/dist/store/MemoryEventStore.js +0 -111
- package/dist/store/WriteQueue.d.ts +0 -51
- package/dist/store/WriteQueue.js +0 -142
- package/dist/store/index.d.ts +0 -6
- package/dist/store/index.js +0 -5
- package/dist/store/types.d.ts +0 -427
- package/dist/store/types.js +0 -19
- package/dist/visitorTimeline.d.ts +0 -24
- package/dist/visitorTimeline.js +0 -68
- package/src/auth.test.ts +0 -90
- package/src/auth.ts +0 -248
- package/src/bridge-auth.test.ts +0 -196
- package/src/bridge.test.ts +0 -1708
- package/src/bridge.ts +0 -1854
- package/src/cli.ts +0 -338
- package/src/dashboardApi.test.ts +0 -235
- package/src/dashboardApi.ts +0 -184
- package/src/dashboardSpa.test.ts +0 -239
- package/src/dashboardSpa.ts +0 -195
- package/src/dashboardUrl.test.ts +0 -46
- package/src/dashboardUrl.ts +0 -28
- package/src/eventsHandler.test.ts +0 -247
- package/src/eventsHandler.ts +0 -136
- package/src/identity.test.ts +0 -109
- package/src/identity.ts +0 -137
- package/src/openBrowser.test.ts +0 -103
- package/src/openBrowser.ts +0 -81
- package/src/remoteBridge.test.ts +0 -119
- package/src/remoteBridge.ts +0 -404
- package/src/replay.test.ts +0 -271
- package/src/replayCreate.ts +0 -194
- package/src/replayViewer.ts +0 -173
- package/src/sessionRouter.ts +0 -119
- package/src/store/JsonMemoryStore.test.ts +0 -175
- package/src/store/JsonMemoryStore.ts +0 -128
- package/src/store/JsonTaskStore.test.ts +0 -212
- package/src/store/JsonTaskStore.ts +0 -59
- package/src/store/JsonlStore.test.ts +0 -1538
- package/src/store/JsonlStore.ts +0 -1325
- package/src/store/MemoryEventStore.test.ts +0 -119
- package/src/store/MemoryEventStore.ts +0 -151
- package/src/store/WriteQueue.ts +0 -165
- package/src/store/identityTagging.test.ts +0 -67
- package/src/store/index.ts +0 -29
- package/src/store/types.ts +0 -532
- package/src/visitorTimeline.test.ts +0 -197
- package/src/visitorTimeline.ts +0 -89
package/src/mcpHttp.test.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
2
|
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
-
import { Bridge } from '
|
|
3
|
+
import { Bridge } from '@harness-fe/daemon';
|
|
4
4
|
import { startMcpHttpServer } from './mcpHttp.js';
|
|
5
|
-
import { MemoryEventStore } from '
|
|
6
|
-
import type { EventStore } from '
|
|
5
|
+
import { MemoryEventStore } from '@harness-fe/daemon';
|
|
6
|
+
import type { EventStore } from '@harness-fe/daemon';
|
|
7
7
|
|
|
8
8
|
const cleanups: Array<() => Promise<void> | void> = [];
|
|
9
9
|
|
|
@@ -99,6 +99,55 @@ describe('mcpHttp', () => {
|
|
|
99
99
|
expect(withAuth.status).not.toBe(401);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
it('supports multiple concurrent clients (per-session transports)', async () => {
|
|
103
|
+
// Regression: the old single shared transport threw "Server already
|
|
104
|
+
// initialized" on the 2nd initialize, blocking multi-agent (gateway) use
|
|
105
|
+
// and any reconnect. Per-session transports must let each client init.
|
|
106
|
+
const bridge = await startBridge();
|
|
107
|
+
const handle = await startMcpHttpServer(bridge, { path: '/mcp' });
|
|
108
|
+
cleanups.push(() => handle.close());
|
|
109
|
+
const port = bridge.getBoundPort()!;
|
|
110
|
+
|
|
111
|
+
const headers = {
|
|
112
|
+
'content-type': 'application/json',
|
|
113
|
+
accept: 'application/json, text/event-stream',
|
|
114
|
+
};
|
|
115
|
+
const initBody = (name: string) =>
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
jsonrpc: '2.0',
|
|
118
|
+
method: 'initialize',
|
|
119
|
+
params: {
|
|
120
|
+
protocolVersion: '2025-06-18',
|
|
121
|
+
capabilities: {},
|
|
122
|
+
clientInfo: { name, version: '1' },
|
|
123
|
+
},
|
|
124
|
+
id: 1,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const r1 = await fetch(`http://127.0.0.1:${port}/mcp`, { method: 'POST', headers, body: initBody('c1') });
|
|
128
|
+
await r1.text();
|
|
129
|
+
const sid1 = r1.headers.get('mcp-session-id');
|
|
130
|
+
expect(r1.status).toBe(200);
|
|
131
|
+
expect(sid1).toBeTruthy();
|
|
132
|
+
|
|
133
|
+
const r2 = await fetch(`http://127.0.0.1:${port}/mcp`, { method: 'POST', headers, body: initBody('c2') });
|
|
134
|
+
await r2.text();
|
|
135
|
+
const sid2 = r2.headers.get('mcp-session-id');
|
|
136
|
+
expect(r2.status).toBe(200);
|
|
137
|
+
expect(sid2).toBeTruthy();
|
|
138
|
+
// Distinct sessions — the whole point of per-session transports.
|
|
139
|
+
expect(sid2).not.toBe(sid1);
|
|
140
|
+
|
|
141
|
+
// A request carrying an unknown session id is rejected (not silently
|
|
142
|
+
// attached to some shared transport).
|
|
143
|
+
const bad = await fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { ...headers, 'mcp-session-id': 'does-not-exist' },
|
|
146
|
+
body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 2 }),
|
|
147
|
+
});
|
|
148
|
+
expect(bad.status).toBe(400);
|
|
149
|
+
});
|
|
150
|
+
|
|
102
151
|
// SSE Last-Event-ID resumption — end-to-end wiring proof.
|
|
103
152
|
//
|
|
104
153
|
// What this asserts:
|
package/src/mcpHttp.ts
CHANGED
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
11
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
12
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
12
13
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
13
|
-
import type { Bridge, IBridge } from '
|
|
14
|
+
import type { Bridge, IBridge } from '@harness-fe/daemon';
|
|
14
15
|
import { createMcpServer } from './mcp.js';
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
16
|
+
import { identifyPrincipal } from '@harness-fe/daemon';
|
|
17
|
+
import { runWithCaller } from '@harness-fe/daemon';
|
|
18
|
+
import { MemoryEventStore } from '@harness-fe/daemon';
|
|
19
|
+
import type { EventStore } from '@harness-fe/daemon';
|
|
17
20
|
|
|
18
21
|
export interface McpHttpOptions {
|
|
19
22
|
/** URL path the transport listens on. Default `/mcp`. */
|
|
@@ -56,44 +59,120 @@ export async function startMcpHttpServer(
|
|
|
56
59
|
): Promise<McpHttpHandle> {
|
|
57
60
|
const path = opts.path ?? '/mcp';
|
|
58
61
|
const stateful = opts.stateful !== false;
|
|
59
|
-
const eventStore =
|
|
60
|
-
opts.eventStore === null
|
|
61
|
-
? undefined
|
|
62
|
-
: opts.eventStore ?? new MemoryEventStore();
|
|
63
|
-
|
|
64
|
-
// Pass the daemon's auth so MCP tools can identify the per-call principal
|
|
65
|
-
// from request headers (4.0 · P4). stdio (startMcpStdioServer) omits this,
|
|
66
|
-
// so stdio calls resolve to the local principal.
|
|
67
|
-
const server = createMcpServer(bridge, {
|
|
68
|
-
experimentalEnvVar: opts.experimentalEnvVar,
|
|
69
|
-
auth: (bridge as Bridge).getAuthOptions(),
|
|
70
|
-
});
|
|
71
|
-
const transport = new StreamableHTTPServerTransport({
|
|
72
|
-
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
73
|
-
eventStore,
|
|
74
|
-
});
|
|
75
|
-
await server.connect(transport);
|
|
76
|
-
|
|
77
62
|
const b = bridge as Bridge;
|
|
78
63
|
if (typeof b.prependHttpHandler !== 'function') {
|
|
79
64
|
throw new Error(
|
|
80
65
|
'mcpHttp: bridge does not support prependHttpHandler (need a Bridge instance with HTTP server)',
|
|
81
66
|
);
|
|
82
67
|
}
|
|
68
|
+
// Pass the daemon's auth so MCP tools can identify the per-call principal
|
|
69
|
+
// from request headers (4.0 · P4). stdio (startMcpStdioServer) omits this,
|
|
70
|
+
// so stdio calls resolve to the local principal.
|
|
71
|
+
const auth = b.getAuthOptions();
|
|
72
|
+
|
|
73
|
+
// Per-session transports (4.0) — the MCP HTTP spec's stateful model: each
|
|
74
|
+
// client gets its own transport + server keyed by `mcp-session-id`, created
|
|
75
|
+
// on `initialize`. The old shape shared ONE transport for the whole daemon,
|
|
76
|
+
// which locked after the first initialize — a second agent (or any reconnect)
|
|
77
|
+
// hit "Server already initialized". Per-session is what lets multiple agents
|
|
78
|
+
// share one daemon through the gateway, and lets a client reconnect.
|
|
79
|
+
type Session = { transport: StreamableHTTPServerTransport; server: ReturnType<typeof createMcpServer> };
|
|
80
|
+
const sessions = new Map<string, Session>();
|
|
81
|
+
|
|
82
|
+
function newSession(): Session {
|
|
83
|
+
const server = createMcpServer(bridge, { experimentalEnvVar: opts.experimentalEnvVar, auth });
|
|
84
|
+
const transport = new StreamableHTTPServerTransport({
|
|
85
|
+
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
86
|
+
// null → resumability off; an explicit store → use it; default → a
|
|
87
|
+
// fresh per-session MemoryEventStore.
|
|
88
|
+
eventStore: opts.eventStore === null ? undefined : (opts.eventStore ?? new MemoryEventStore()),
|
|
89
|
+
onsessioninitialized: (sid: string) => {
|
|
90
|
+
sessions.set(sid, { transport, server });
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
transport.onclose = () => {
|
|
94
|
+
const sid = transport.sessionId;
|
|
95
|
+
if (sid) sessions.delete(sid);
|
|
96
|
+
};
|
|
97
|
+
return { transport, server };
|
|
98
|
+
}
|
|
83
99
|
|
|
84
100
|
b.prependHttpHandler(async (req: IncomingMessage, res: ServerResponse) => {
|
|
85
101
|
const url = req.url ?? '';
|
|
86
102
|
const qi = url.indexOf('?');
|
|
87
103
|
const reqPath = qi < 0 ? url : url.slice(0, qi);
|
|
88
104
|
if (reqPath !== path) return false;
|
|
89
|
-
|
|
105
|
+
|
|
106
|
+
// Per-call caller for command-target scoping (4.0 · A): every sendCommand
|
|
107
|
+
// within this request reads it via currentCaller().
|
|
108
|
+
const principal = identifyPrincipal(req.headers, auth);
|
|
109
|
+
const sid = req.headers['mcp-session-id'];
|
|
110
|
+
|
|
111
|
+
// Established session — route by id (POST follow-ups, GET SSE, DELETE).
|
|
112
|
+
if (typeof sid === 'string' && sessions.has(sid)) {
|
|
113
|
+
const { transport } = sessions.get(sid)!;
|
|
114
|
+
await runWithCaller(principal, () => transport.handleRequest(req, res));
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// No (known) session id. A POST `initialize` opens one; anything else is invalid.
|
|
119
|
+
if (req.method === 'POST') {
|
|
120
|
+
let body: unknown;
|
|
121
|
+
try {
|
|
122
|
+
body = await readJsonBody(req);
|
|
123
|
+
} catch {
|
|
124
|
+
sendMcpError(res, 400, -32700, 'Parse error');
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (stateful && !isInitializeRequest(body)) {
|
|
128
|
+
sendMcpError(res, 400, -32600, 'Bad Request: no valid mcp-session-id (initialize first)');
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
const { server, transport } = newSession();
|
|
132
|
+
await server.connect(transport);
|
|
133
|
+
if (!stateful) {
|
|
134
|
+
// Stateless one-shot: tear down when the response ends.
|
|
135
|
+
res.on('close', () => {
|
|
136
|
+
void transport.close();
|
|
137
|
+
void server.close();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
await runWithCaller(principal, () => transport.handleRequest(req, res, body));
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// GET/DELETE without a known session — nothing to attach to.
|
|
145
|
+
sendMcpError(res, 400, -32600, 'Bad Request: unknown or missing mcp-session-id');
|
|
90
146
|
return true;
|
|
91
147
|
});
|
|
92
148
|
|
|
93
149
|
return {
|
|
94
150
|
path,
|
|
95
151
|
async close() {
|
|
96
|
-
|
|
152
|
+
for (const { server } of sessions.values()) {
|
|
153
|
+
await server.close().catch(() => undefined);
|
|
154
|
+
}
|
|
155
|
+
sessions.clear();
|
|
97
156
|
},
|
|
98
157
|
};
|
|
99
158
|
}
|
|
159
|
+
|
|
160
|
+
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
161
|
+
const chunks: Buffer[] = [];
|
|
162
|
+
let total = 0;
|
|
163
|
+
const MAX = 4 * 1024 * 1024; // 4 MiB cap — MCP requests are small
|
|
164
|
+
for await (const c of req) {
|
|
165
|
+
const buf = c as Buffer;
|
|
166
|
+
total += buf.length;
|
|
167
|
+
if (total > MAX) throw new Error('mcp body too large');
|
|
168
|
+
chunks.push(buf);
|
|
169
|
+
}
|
|
170
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
171
|
+
return raw ? (JSON.parse(raw) as unknown) : undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function sendMcpError(res: ServerResponse, status: number, code: number, message: string): void {
|
|
175
|
+
res.statusCode = status;
|
|
176
|
+
res.setHeader('content-type', 'application/json');
|
|
177
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null }));
|
|
178
|
+
}
|
package/src/mcpLayer.e2e.test.ts
CHANGED
|
@@ -16,8 +16,8 @@ import { join } from 'node:path';
|
|
|
16
16
|
import { randomUUID } from 'node:crypto';
|
|
17
17
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
18
18
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
19
|
-
import { Bridge } from '
|
|
20
|
-
import { JsonlStore } from '
|
|
19
|
+
import { Bridge } from '@harness-fe/daemon';
|
|
20
|
+
import { JsonlStore } from '@harness-fe/daemon';
|
|
21
21
|
import { createMcpServer } from './mcp.js';
|
|
22
22
|
import type {
|
|
23
23
|
EventFrame,
|
|
@@ -14,8 +14,8 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { randomUUID } from 'node:crypto';
|
|
17
|
-
import { Bridge } from '
|
|
18
|
-
import { JsonlStore } from '
|
|
17
|
+
import { Bridge } from '@harness-fe/daemon';
|
|
18
|
+
import { JsonlStore } from '@harness-fe/daemon';
|
|
19
19
|
import type {
|
|
20
20
|
EventFrame,
|
|
21
21
|
HelloAckFrame,
|
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
StorageEntry,
|
|
24
24
|
WsEntry,
|
|
25
25
|
} from '@harness-fe/protocol';
|
|
26
|
-
import { buildVisitorTimeline } from '
|
|
26
|
+
import { buildVisitorTimeline } from '@harness-fe/daemon';
|
|
27
27
|
|
|
28
28
|
interface TestEnv {
|
|
29
29
|
bridge: Bridge;
|
package/dist/auth.d.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Token-based auth for the bridge's HTTP + WS surfaces.
|
|
3
|
-
*
|
|
4
|
-
* Loopback (127.*, localhost, ::1) — auth disabled by default; the daemon
|
|
5
|
-
* trusts everything that can reach the loopback socket. As soon as the
|
|
6
|
-
* daemon is bound to a non-loopback host (e.g. 0.0.0.0 for LAN debugging),
|
|
7
|
-
* the CLI requires a token and this module enforces it on every HTTP route
|
|
8
|
-
* and WS upgrade.
|
|
9
|
-
*
|
|
10
|
-
* Why a single module: dashboard / replay viewer / events handler /
|
|
11
|
-
* MCP HTTP transport all live behind the same bridge HTTP server. Bridge
|
|
12
|
-
* wraps requests with `isAuthorized` once, so individual handlers never
|
|
13
|
-
* see unauthenticated traffic and don't carry auth code.
|
|
14
|
-
*/
|
|
15
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
16
|
-
export declare const DEFAULT_COOKIE_NAME = "harness_fe_token";
|
|
17
|
-
export declare const DEFAULT_LOGIN_PATH = "/__auth";
|
|
18
|
-
export interface AuthOptions {
|
|
19
|
-
/** Expected token. Empty/undefined disables token auth. */
|
|
20
|
-
token?: string;
|
|
21
|
-
/**
|
|
22
|
-
* Custom authorization predicate. When supplied, runs *instead of* the
|
|
23
|
-
* token check on every HTTP request and WS upgrade. Synchronous: the
|
|
24
|
-
* WS upgrade handshake completes inline. For host-injected auth that
|
|
25
|
-
* needs an async lookup, cache the result in a cookie via the host's
|
|
26
|
-
* own middleware and have `authorize` read the cookie.
|
|
27
|
-
*/
|
|
28
|
-
authorize?: (req: IncomingMessage) => boolean;
|
|
29
|
-
/** Cookie name set after a successful login. Default: harness_fe_token. */
|
|
30
|
-
cookieName?: string;
|
|
31
|
-
/** POST path that consumes the login form. Default: /__auth. */
|
|
32
|
-
loginPath?: string;
|
|
33
|
-
}
|
|
34
|
-
export declare function isAuthEnabled(opts: AuthOptions): boolean;
|
|
35
|
-
/** Pull token from header / cookie / query / WS subprotocol (first match wins). */
|
|
36
|
-
export declare function extractToken(req: IncomingMessage, opts?: AuthOptions): string | undefined;
|
|
37
|
-
/**
|
|
38
|
-
* Constant-time token compare. Hashing both sides first means we always
|
|
39
|
-
* compare equal-length buffers, sidestepping the length-leak that a raw
|
|
40
|
-
* timingSafeEqual on user input would have.
|
|
41
|
-
*/
|
|
42
|
-
export declare function verifyToken(provided: string | undefined, expected: string): boolean;
|
|
43
|
-
/** True if request is allowed (auth disabled, custom predicate accepts, or token matches). */
|
|
44
|
-
export declare function isAuthorized(req: IncomingMessage, opts: AuthOptions): boolean;
|
|
45
|
-
/**
|
|
46
|
-
* Write a 401 response. Browsers (Accept: text/html) get a minimal login
|
|
47
|
-
* form they can post the token through; everything else gets JSON.
|
|
48
|
-
*/
|
|
49
|
-
export declare function sendUnauthorized(req: IncomingMessage, res: ServerResponse, opts: AuthOptions): void;
|
|
50
|
-
/**
|
|
51
|
-
* Handle POST {loginPath}: read form body, verify token, set cookie, 303 → next.
|
|
52
|
-
*/
|
|
53
|
-
export declare function handleLoginPost(req: IncomingMessage, res: ServerResponse, opts: AuthOptions): Promise<void>;
|
package/dist/auth.js
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Token-based auth for the bridge's HTTP + WS surfaces.
|
|
3
|
-
*
|
|
4
|
-
* Loopback (127.*, localhost, ::1) — auth disabled by default; the daemon
|
|
5
|
-
* trusts everything that can reach the loopback socket. As soon as the
|
|
6
|
-
* daemon is bound to a non-loopback host (e.g. 0.0.0.0 for LAN debugging),
|
|
7
|
-
* the CLI requires a token and this module enforces it on every HTTP route
|
|
8
|
-
* and WS upgrade.
|
|
9
|
-
*
|
|
10
|
-
* Why a single module: dashboard / replay viewer / events handler /
|
|
11
|
-
* MCP HTTP transport all live behind the same bridge HTTP server. Bridge
|
|
12
|
-
* wraps requests with `isAuthorized` once, so individual handlers never
|
|
13
|
-
* see unauthenticated traffic and don't carry auth code.
|
|
14
|
-
*/
|
|
15
|
-
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
16
|
-
export const DEFAULT_COOKIE_NAME = 'harness_fe_token';
|
|
17
|
-
export const DEFAULT_LOGIN_PATH = '/__auth';
|
|
18
|
-
const WS_SUBPROTOCOL_PREFIX = 'harness-fe.token.';
|
|
19
|
-
export function isAuthEnabled(opts) {
|
|
20
|
-
return !!(opts.token || opts.authorize);
|
|
21
|
-
}
|
|
22
|
-
/** Pull token from header / cookie / query / WS subprotocol (first match wins). */
|
|
23
|
-
export function extractToken(req, opts = {}) {
|
|
24
|
-
const auth = req.headers.authorization;
|
|
25
|
-
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
|
26
|
-
const v = auth.slice(7).trim();
|
|
27
|
-
if (v)
|
|
28
|
-
return v;
|
|
29
|
-
}
|
|
30
|
-
const cookieName = opts.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
31
|
-
const cookies = parseCookieHeader(req.headers.cookie);
|
|
32
|
-
if (cookies[cookieName])
|
|
33
|
-
return decodeURIComponent(cookies[cookieName]);
|
|
34
|
-
const url = req.url ?? '';
|
|
35
|
-
const qi = url.indexOf('?');
|
|
36
|
-
if (qi >= 0) {
|
|
37
|
-
const params = new URLSearchParams(url.slice(qi + 1));
|
|
38
|
-
const t = params.get('token');
|
|
39
|
-
if (t)
|
|
40
|
-
return t;
|
|
41
|
-
}
|
|
42
|
-
const subproto = req.headers['sec-websocket-protocol'];
|
|
43
|
-
if (typeof subproto === 'string') {
|
|
44
|
-
for (const p of subproto.split(',')) {
|
|
45
|
-
const trimmed = p.trim();
|
|
46
|
-
if (trimmed.startsWith(WS_SUBPROTOCOL_PREFIX)) {
|
|
47
|
-
return trimmed.slice(WS_SUBPROTOCOL_PREFIX.length);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return undefined;
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Constant-time token compare. Hashing both sides first means we always
|
|
55
|
-
* compare equal-length buffers, sidestepping the length-leak that a raw
|
|
56
|
-
* timingSafeEqual on user input would have.
|
|
57
|
-
*/
|
|
58
|
-
export function verifyToken(provided, expected) {
|
|
59
|
-
if (!provided || !expected)
|
|
60
|
-
return false;
|
|
61
|
-
const a = createHash('sha256').update(provided).digest();
|
|
62
|
-
const b = createHash('sha256').update(expected).digest();
|
|
63
|
-
return timingSafeEqual(a, b);
|
|
64
|
-
}
|
|
65
|
-
/** True if request is allowed (auth disabled, custom predicate accepts, or token matches). */
|
|
66
|
-
export function isAuthorized(req, opts) {
|
|
67
|
-
if (!isAuthEnabled(opts))
|
|
68
|
-
return true;
|
|
69
|
-
// Custom predicate wins when supplied. Hosts that embed the daemon pass
|
|
70
|
-
// their own check here (e.g. JWT verification reading from a cookie).
|
|
71
|
-
if (opts.authorize)
|
|
72
|
-
return opts.authorize(req);
|
|
73
|
-
return verifyToken(extractToken(req, opts), opts.token);
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Write a 401 response. Browsers (Accept: text/html) get a minimal login
|
|
77
|
-
* form they can post the token through; everything else gets JSON.
|
|
78
|
-
*/
|
|
79
|
-
export function sendUnauthorized(req, res, opts) {
|
|
80
|
-
// Custom-authorize mode is for host apps that own their own login UX —
|
|
81
|
-
// the built-in token form is never the right answer there. Always 401
|
|
82
|
-
// as JSON and let the host redirect.
|
|
83
|
-
const wantsLoginForm = !opts.authorize;
|
|
84
|
-
const accept = (req.headers.accept ?? '').toLowerCase();
|
|
85
|
-
const wantsHtml = accept.includes('text/html') && wantsLoginForm;
|
|
86
|
-
if (wantsHtml) {
|
|
87
|
-
res.statusCode = 401;
|
|
88
|
-
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
89
|
-
res.setHeader('cache-control', 'no-store');
|
|
90
|
-
res.end(renderLoginPage(opts, req.url ?? '/'));
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
res.statusCode = 401;
|
|
94
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
95
|
-
res.setHeader('www-authenticate', 'Bearer realm="harness-fe"');
|
|
96
|
-
res.end(JSON.stringify({
|
|
97
|
-
error: 'unauthorized',
|
|
98
|
-
message: 'Missing or invalid token. Provide Authorization: Bearer <token>, ?token=<token>, or the harness_fe_token cookie.',
|
|
99
|
-
}));
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Handle POST {loginPath}: read form body, verify token, set cookie, 303 → next.
|
|
103
|
-
*/
|
|
104
|
-
export async function handleLoginPost(req, res, opts) {
|
|
105
|
-
if (!isAuthEnabled(opts) || opts.authorize) {
|
|
106
|
-
// Auth disabled, or the host owns auth via a custom predicate — the
|
|
107
|
-
// built-in login form isn't meaningful here. Redirect home.
|
|
108
|
-
res.statusCode = 303;
|
|
109
|
-
res.setHeader('location', '/');
|
|
110
|
-
res.end();
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
const chunks = [];
|
|
114
|
-
let total = 0;
|
|
115
|
-
const MAX = 4096;
|
|
116
|
-
for await (const c of req) {
|
|
117
|
-
const buf = c;
|
|
118
|
-
total += buf.length;
|
|
119
|
-
if (total > MAX) {
|
|
120
|
-
res.statusCode = 413;
|
|
121
|
-
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
122
|
-
res.end('payload too large');
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
chunks.push(buf);
|
|
126
|
-
}
|
|
127
|
-
const body = Buffer.concat(chunks).toString('utf8');
|
|
128
|
-
const form = new URLSearchParams(body);
|
|
129
|
-
const token = form.get('token') ?? '';
|
|
130
|
-
const next = safeNext(form.get('next') ?? '/');
|
|
131
|
-
if (!verifyToken(token, opts.token)) {
|
|
132
|
-
res.statusCode = 401;
|
|
133
|
-
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
134
|
-
res.setHeader('cache-control', 'no-store');
|
|
135
|
-
res.end(renderLoginPage(opts, next, 'Invalid token. Try again.'));
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
const cookieName = opts.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
139
|
-
// 30 days. HttpOnly so JS can't read it; SameSite=Lax so cross-tab nav works.
|
|
140
|
-
const cookie = `${cookieName}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`;
|
|
141
|
-
res.statusCode = 303;
|
|
142
|
-
res.setHeader('set-cookie', cookie);
|
|
143
|
-
res.setHeader('location', next);
|
|
144
|
-
res.end();
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Allow only same-origin relative paths as the post-login redirect. Anything
|
|
148
|
-
* else degrades to "/" so a crafted form can't redirect to an external site.
|
|
149
|
-
*/
|
|
150
|
-
function safeNext(next) {
|
|
151
|
-
if (typeof next !== 'string')
|
|
152
|
-
return '/';
|
|
153
|
-
if (!next.startsWith('/'))
|
|
154
|
-
return '/';
|
|
155
|
-
if (next.startsWith('//'))
|
|
156
|
-
return '/';
|
|
157
|
-
return next;
|
|
158
|
-
}
|
|
159
|
-
function parseCookieHeader(raw) {
|
|
160
|
-
if (!raw)
|
|
161
|
-
return {};
|
|
162
|
-
const out = {};
|
|
163
|
-
for (const part of raw.split(';')) {
|
|
164
|
-
const eq = part.indexOf('=');
|
|
165
|
-
if (eq < 0)
|
|
166
|
-
continue;
|
|
167
|
-
const k = part.slice(0, eq).trim();
|
|
168
|
-
const v = part.slice(eq + 1).trim();
|
|
169
|
-
if (k)
|
|
170
|
-
out[k] = v;
|
|
171
|
-
}
|
|
172
|
-
return out;
|
|
173
|
-
}
|
|
174
|
-
function escapeHtml(s) {
|
|
175
|
-
return s.replace(/[&<>"']/g, (c) => {
|
|
176
|
-
switch (c) {
|
|
177
|
-
case '&': return '&';
|
|
178
|
-
case '<': return '<';
|
|
179
|
-
case '>': return '>';
|
|
180
|
-
case '"': return '"';
|
|
181
|
-
default: return ''';
|
|
182
|
-
}
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
function renderLoginPage(opts, next, error) {
|
|
186
|
-
const loginPath = opts.loginPath ?? DEFAULT_LOGIN_PATH;
|
|
187
|
-
const safeN = escapeHtml(safeNext(next));
|
|
188
|
-
const errBlock = error
|
|
189
|
-
? `<p style="color:#c0392b;margin:0 0 12px">${escapeHtml(error)}</p>`
|
|
190
|
-
: '';
|
|
191
|
-
return `<!doctype html>
|
|
192
|
-
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
193
|
-
<title>harness-fe — sign in</title>
|
|
194
|
-
<style>
|
|
195
|
-
body{font:14px/1.4 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;background:#fafafa;color:#222;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
|
196
|
-
form{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:24px;max-width:360px;width:100%;box-shadow:0 4px 12px rgba(0,0,0,.04)}
|
|
197
|
-
h1{font-size:16px;margin:0 0 12px}
|
|
198
|
-
input[type=password]{display:block;width:100%;box-sizing:border-box;padding:10px;border:1px solid #d1d5db;border-radius:6px;font-size:14px;margin-bottom:12px}
|
|
199
|
-
button{display:block;width:100%;padding:10px;background:#111;color:#fff;border:0;border-radius:6px;font-size:14px;cursor:pointer}
|
|
200
|
-
.muted{color:#666;font-size:12px;margin-top:12px}
|
|
201
|
-
</style></head>
|
|
202
|
-
<body>
|
|
203
|
-
<form method="post" action="${escapeHtml(loginPath)}" autocomplete="off">
|
|
204
|
-
<h1>harness-fe</h1>
|
|
205
|
-
${errBlock}
|
|
206
|
-
<input type="password" name="token" placeholder="token" autofocus required>
|
|
207
|
-
<input type="hidden" name="next" value="${safeN}">
|
|
208
|
-
<button type="submit">Sign in</button>
|
|
209
|
-
<p class="muted">Paste the token from the daemon startup banner.</p>
|
|
210
|
-
</form>
|
|
211
|
-
</body></html>`;
|
|
212
|
-
}
|