@runuai/host 0.9.150 → 0.9.152
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/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1837 -224
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +650 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1061 -139
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- package/src/ui/server.ts +48 -10
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §11 on the sign-in box: an MCP connection's handshake — probe,
|
|
3
|
+
* discovery (RFC 9728 → RFC 8414), dynamic registration (RFC 7591), PKCE and
|
|
4
|
+
* the code exchange — every request through the SSRF rule (safe-http.ts),
|
|
5
|
+
* written as one immutable revision exactly where the cloud's flow says, and
|
|
6
|
+
* never into this box's own store.
|
|
7
|
+
*
|
|
8
|
+
* - A static header the server accepts is the lease-readable part.
|
|
9
|
+
* - An OAuth sign-in writes the broker-only part first (the refresh token,
|
|
10
|
+
* the client and the token endpoint, which only the broker reads), then
|
|
11
|
+
* the lease-readable part (the access token and its expiry).
|
|
12
|
+
* - A server that wants nothing is a `none` connection: nothing is written.
|
|
13
|
+
*
|
|
14
|
+
* Between the probe and the callback, the PKCE verifier and client secret
|
|
15
|
+
* wait in this process's memory, bound to the exact flow and the
|
|
16
|
+
* authorization server's issuer, for ten minutes at most (or until the cloud
|
|
17
|
+
* cancels the flow), and are taken once. A box restarted meanwhile says so:
|
|
18
|
+
* the person connects again.
|
|
19
|
+
*
|
|
20
|
+
* The server and everything it names are untrusted:
|
|
21
|
+
* - discovery takes protected-resource metadata only for this resource
|
|
22
|
+
* (RFC 9728) and authorization-server metadata only from its own issuer
|
|
23
|
+
* (RFC 8414); a callback must name that issuer when the server supports it
|
|
24
|
+
* (RFC 9207), and otherwise the server's endpoints must sit on its own
|
|
25
|
+
* origin, so a code meant for one server never reaches another (RFC 9700
|
|
26
|
+
* §4.4, mix-up);
|
|
27
|
+
* - nothing a server says is relayed to the cloud: answers carry fixed
|
|
28
|
+
* messages, OAuth error codes from the standard list, and scopes only as a
|
|
29
|
+
* subset of those asked for;
|
|
30
|
+
* - the sign-in page handed to the browser obeys the same public-address
|
|
31
|
+
* rule as every request;
|
|
32
|
+
* - each step, its credential writes included, finishes inside the cloud's
|
|
33
|
+
* answer deadline, and the cloud's cancel stops a step where it is (HTTP,
|
|
34
|
+
* registration, the code exchange or a write), so nothing the cloud gave
|
|
35
|
+
* up on keeps running.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
isScopeList,
|
|
42
|
+
serializeLeasedValue,
|
|
43
|
+
type CaptureFlowWire,
|
|
44
|
+
type CaptureResultWire,
|
|
45
|
+
type CredentialLocation,
|
|
46
|
+
type McpCaptureAckFrame,
|
|
47
|
+
type McpCaptureCancelFrame,
|
|
48
|
+
type McpCaptureCompleteFrame,
|
|
49
|
+
type McpCaptureProbeFrame,
|
|
50
|
+
} from "../src/cloud-credentials";
|
|
51
|
+
import { parseCaptureFlow, writeCapture, type ParsedCaptureFlow } from "./capture-flow";
|
|
52
|
+
import type { CredentialStore } from "./credential-store";
|
|
53
|
+
import {
|
|
54
|
+
SafeHttpRefusal,
|
|
55
|
+
parseSafeUrl,
|
|
56
|
+
safeJson,
|
|
57
|
+
safeRequest,
|
|
58
|
+
vetPublicUrl,
|
|
59
|
+
type SafeHttpDeps,
|
|
60
|
+
type SafeHttpLimits,
|
|
61
|
+
} from "./safe-http";
|
|
62
|
+
import { emptyRefreshMeta, refreshMetaFrom, type RefreshMeta } from "./token-lanes/types";
|
|
63
|
+
|
|
64
|
+
export const MCP_OAUTH_LANE = "mcp-oauth";
|
|
65
|
+
|
|
66
|
+
/** What only the broker reads for an MCP OAuth connection. */
|
|
67
|
+
export interface BrokerMcpValue {
|
|
68
|
+
v: 1;
|
|
69
|
+
part: "broker";
|
|
70
|
+
location: CredentialLocation;
|
|
71
|
+
class: "mcp";
|
|
72
|
+
lane: typeof MCP_OAUTH_LANE;
|
|
73
|
+
tokenEndpoint: string;
|
|
74
|
+
clientId: string;
|
|
75
|
+
clientSecret: string | null;
|
|
76
|
+
/** The MCP server URL, sent as RFC 8707 `resource` on every grant. */
|
|
77
|
+
resource: string;
|
|
78
|
+
accessToken: string;
|
|
79
|
+
/** Null when the server gave none: the sign-in lapses with its token. */
|
|
80
|
+
refreshToken: string | null;
|
|
81
|
+
scopes: string | null;
|
|
82
|
+
refresh: RefreshMeta;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type AckBody = Omit<McpCaptureAckFrame, "kind" | "opId">;
|
|
86
|
+
|
|
87
|
+
const PENDING_TTL_MS = 10 * 60_000;
|
|
88
|
+
/** The most sign-ins waiting at once; the oldest goes first past it. */
|
|
89
|
+
export const MAX_PENDING_SIGN_INS = 256;
|
|
90
|
+
/** One step's whole budget, inside the bridge's 45-second answer deadline. */
|
|
91
|
+
export const OPERATION_DEADLINE_MS = 40_000;
|
|
92
|
+
const DISCOVERY_MAX_BYTES = 256 * 1024;
|
|
93
|
+
|
|
94
|
+
/** The OAuth error codes an answer may name (RFC 6749, 7591, 8707). */
|
|
95
|
+
const OAUTH_ERROR_CODES = new Set([
|
|
96
|
+
"invalid_request",
|
|
97
|
+
"invalid_client",
|
|
98
|
+
"invalid_grant",
|
|
99
|
+
"unauthorized_client",
|
|
100
|
+
"unsupported_grant_type",
|
|
101
|
+
"invalid_scope",
|
|
102
|
+
"access_denied",
|
|
103
|
+
"server_error",
|
|
104
|
+
"temporarily_unavailable",
|
|
105
|
+
"invalid_target",
|
|
106
|
+
"invalid_redirect_uri",
|
|
107
|
+
"invalid_client_metadata",
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
/** A token or registration endpoint's failure, never in its own words. */
|
|
111
|
+
export function oauthFailure(json: Record<string, unknown>, status: number): string {
|
|
112
|
+
return typeof json.error === "string" && OAUTH_ERROR_CODES.has(json.error) ? json.error : `HTTP ${status}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface PendingSignIn {
|
|
116
|
+
/** The exact flow the probe was for: completion uses this, not the frame's. */
|
|
117
|
+
flow: CaptureFlowWire;
|
|
118
|
+
state: string;
|
|
119
|
+
verifier: string;
|
|
120
|
+
clientId: string;
|
|
121
|
+
clientSecret: string | null;
|
|
122
|
+
issuer: string;
|
|
123
|
+
/** The server sends RFC 9207 `iss`; a callback without it is refused. */
|
|
124
|
+
issRequired: boolean;
|
|
125
|
+
tokenEndpoint: string;
|
|
126
|
+
redirectUri: string;
|
|
127
|
+
resource: string;
|
|
128
|
+
/** What was asked for, space-separated, or null for the default. */
|
|
129
|
+
scopes: string | null;
|
|
130
|
+
expiresAt: number;
|
|
131
|
+
timer: NodeJS.Timeout;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const pendingSignIns = new Map<string, PendingSignIn>();
|
|
135
|
+
|
|
136
|
+
function flowKey(flow: { credentialId: string; revision: string }): string {
|
|
137
|
+
return `${flow.credentialId}\u0000${flow.revision}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sameFlow(a: CaptureFlowWire, b: CaptureFlowWire): boolean {
|
|
141
|
+
return (
|
|
142
|
+
a.credentialId === b.credentialId &&
|
|
143
|
+
a.revision === b.revision &&
|
|
144
|
+
a.leaseParameter === b.leaseParameter &&
|
|
145
|
+
a.brokerParameter === b.brokerParameter &&
|
|
146
|
+
a.orgId === b.orgId &&
|
|
147
|
+
a.userId === b.userId
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function forgetPending(key: string, entry?: PendingSignIn): void {
|
|
152
|
+
const current = pendingSignIns.get(key);
|
|
153
|
+
if (!current || (entry && current !== entry)) return;
|
|
154
|
+
clearTimeout(current.timer);
|
|
155
|
+
pendingSignIns.delete(key);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function rememberPending(key: string, entry: Omit<PendingSignIn, "timer">): void {
|
|
159
|
+
forgetPending(key);
|
|
160
|
+
while (pendingSignIns.size >= MAX_PENDING_SIGN_INS) {
|
|
161
|
+
const oldest = pendingSignIns.keys().next().value;
|
|
162
|
+
if (oldest === undefined) break;
|
|
163
|
+
forgetPending(oldest);
|
|
164
|
+
}
|
|
165
|
+
const pending = { ...entry } as PendingSignIn;
|
|
166
|
+
pending.timer = setTimeout(() => forgetPending(key, pending), Math.max(0, entry.expiresAt - Date.now()));
|
|
167
|
+
pending.timer.unref();
|
|
168
|
+
pendingSignIns.set(key, pending);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A probe or completion under way, stoppable by the cloud's cancel. */
|
|
172
|
+
interface Step {
|
|
173
|
+
key: string;
|
|
174
|
+
state: string;
|
|
175
|
+
flow: CaptureFlowWire;
|
|
176
|
+
controller: AbortController;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const steps = new Set<Step>();
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Flows the cloud cancelled, so a step for one never begins (or installs a
|
|
183
|
+
* sign-in) afterwards: by flow and state, until it would have expired. A
|
|
184
|
+
* marker is never dropped while it lasts, and the table never grows past
|
|
185
|
+
* MAX_CANCELLED_FLOWS: a cancel that finds it full is kept as one overflow
|
|
186
|
+
* horizon instead, and until that passes no new step begins at all (fail
|
|
187
|
+
* closed, in bounded memory).
|
|
188
|
+
*/
|
|
189
|
+
const cancelledFlows = new Map<string, number>();
|
|
190
|
+
export const MAX_CANCELLED_FLOWS = 4096;
|
|
191
|
+
let maxCancelledFlows = MAX_CANCELLED_FLOWS;
|
|
192
|
+
/** Until when a cancel the table had no room for may still apply to any
|
|
193
|
+
* flow; 0 when none. */
|
|
194
|
+
let overflowUntil = 0;
|
|
195
|
+
|
|
196
|
+
/** Tests only. */
|
|
197
|
+
export function setMaxCancelledFlowsForTesting(limit: number | null): void {
|
|
198
|
+
maxCancelledFlows = limit ?? MAX_CANCELLED_FLOWS;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Drop the markers that ran out: all lasting the same time, the oldest
|
|
202
|
+
* inserted runs out first. */
|
|
203
|
+
function pruneCancelled(now: number): void {
|
|
204
|
+
for (const [key, until] of cancelledFlows) {
|
|
205
|
+
if (until > now) return;
|
|
206
|
+
cancelledFlows.delete(key);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function cancelledKey(key: string, state: string): string {
|
|
211
|
+
return `${key}\u0000${state}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isCancelled(key: string, state: string, now: number): boolean {
|
|
215
|
+
const until = cancelledFlows.get(cancelledKey(key, state));
|
|
216
|
+
if (until === undefined) return false;
|
|
217
|
+
if (until > now) return true;
|
|
218
|
+
cancelledFlows.delete(cancelledKey(key, state));
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Remember a cancel, never at the cost of another live marker: exactly
|
|
223
|
+
* while the table has room, else as the overflow horizon. */
|
|
224
|
+
function rememberCancelled(key: string, state: string, now: number): void {
|
|
225
|
+
pruneCancelled(now);
|
|
226
|
+
const marker = cancelledKey(key, state);
|
|
227
|
+
if (cancelledFlows.has(marker) || cancelledFlows.size < maxCancelledFlows) {
|
|
228
|
+
cancelledFlows.delete(marker);
|
|
229
|
+
cancelledFlows.set(marker, now + PENDING_TTL_MS);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
overflowUntil = Math.max(overflowUntil, now + PENDING_TTL_MS);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
type BeganStep = { signal: AbortSignal; end: () => void };
|
|
236
|
+
|
|
237
|
+
/** Begin a step for this flow: refused when the cloud already cancelled it,
|
|
238
|
+
* or when too many cancels are live to tell. Its signal ends at the step's
|
|
239
|
+
* deadline or at the cloud's cancel. */
|
|
240
|
+
function beginStep(
|
|
241
|
+
flow: CaptureFlowWire,
|
|
242
|
+
state: string,
|
|
243
|
+
deadlineMs: number,
|
|
244
|
+
now: number,
|
|
245
|
+
): BeganStep | "cancelled" | "busy" {
|
|
246
|
+
const key = flowKey(flow);
|
|
247
|
+
if (isCancelled(key, state, now)) return "cancelled";
|
|
248
|
+
// A cancel recorded only as the overflow horizon could be this flow's.
|
|
249
|
+
if (overflowUntil > now) return "busy";
|
|
250
|
+
const step: Step = { key, state, flow: { ...flow }, controller: new AbortController() };
|
|
251
|
+
steps.add(step);
|
|
252
|
+
return {
|
|
253
|
+
signal: AbortSignal.any([step.controller.signal, AbortSignal.timeout(deadlineMs)]),
|
|
254
|
+
end: () => steps.delete(step),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function refusedStep(outcome: "cancelled" | "busy"): AckBody {
|
|
259
|
+
return error(outcome === "busy" ? "the sign-in box is busy; try again shortly" : "cancelled");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Test seam. */
|
|
263
|
+
export function pendingSignInForTesting(flow: { credentialId: string; revision: string }): boolean {
|
|
264
|
+
return pendingSignIns.has(flowKey(flow));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Tests only. */
|
|
268
|
+
export function pendingSignInCountForTesting(): number {
|
|
269
|
+
return pendingSignIns.size;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Tests only. */
|
|
273
|
+
export function stepsUnderWayForTesting(): number {
|
|
274
|
+
return steps.size;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Tests only: forget every cancelled flow. */
|
|
278
|
+
export function resetCancelledFlowsForTesting(): void {
|
|
279
|
+
cancelledFlows.clear();
|
|
280
|
+
overflowUntil = 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Tests only. */
|
|
284
|
+
export function cancelledFlowCountForTesting(): number {
|
|
285
|
+
return cancelledFlows.size;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The cloud ended this flow: a step under way for it stops where it is, its
|
|
290
|
+
* verifier and secret are forgotten now, and a step for it that has not
|
|
291
|
+
* begun yet never will.
|
|
292
|
+
*/
|
|
293
|
+
export function cancelMcpCapture(frame: McpCaptureCancelFrame, now: number = Date.now()): void {
|
|
294
|
+
const key = flowKey(frame.flow);
|
|
295
|
+
for (const step of steps) {
|
|
296
|
+
if (step.key === key && step.state === frame.state && sameFlow(step.flow, frame.flow)) step.controller.abort();
|
|
297
|
+
}
|
|
298
|
+
const pending = pendingSignIns.get(key);
|
|
299
|
+
if (pending && pending.state === frame.state && sameFlow(pending.flow, frame.flow)) forgetPending(key);
|
|
300
|
+
rememberCancelled(key, frame.state, now);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface McpCaptureDeps {
|
|
304
|
+
store: CredentialStore;
|
|
305
|
+
http?: SafeHttpDeps;
|
|
306
|
+
limits?: SafeHttpLimits;
|
|
307
|
+
now?: () => number;
|
|
308
|
+
/** Tests only: a step's whole budget. */
|
|
309
|
+
deadlineMs?: number;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function error(message: string): AckBody {
|
|
313
|
+
return { outcome: "error", capture: null, authorizeUrl: null, scopes: null, error: message.slice(0, 500) };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function b64url(buf: Buffer): string {
|
|
317
|
+
return buf.toString("base64url");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** POST an MCP `initialize` (streamable HTTP): the status and the auth
|
|
321
|
+
* challenge only. The body is never read, so nothing in it travels on. */
|
|
322
|
+
async function initialize(
|
|
323
|
+
url: string,
|
|
324
|
+
header: { name: string; value: string } | null,
|
|
325
|
+
signal: AbortSignal,
|
|
326
|
+
deps: McpCaptureDeps,
|
|
327
|
+
): Promise<{ status: number; wwwAuthenticate: string | null }> {
|
|
328
|
+
const response = await safeRequest(
|
|
329
|
+
{
|
|
330
|
+
url,
|
|
331
|
+
method: "POST",
|
|
332
|
+
headers: {
|
|
333
|
+
"content-type": "application/json",
|
|
334
|
+
accept: "application/json, text/event-stream",
|
|
335
|
+
...(header ? { [header.name]: header.value } : {}),
|
|
336
|
+
},
|
|
337
|
+
body: JSON.stringify({
|
|
338
|
+
jsonrpc: "2.0",
|
|
339
|
+
id: 0,
|
|
340
|
+
method: "initialize",
|
|
341
|
+
params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "uai", version: "1.0" } },
|
|
342
|
+
}),
|
|
343
|
+
signal,
|
|
344
|
+
},
|
|
345
|
+
deps.limits,
|
|
346
|
+
deps.http,
|
|
347
|
+
);
|
|
348
|
+
response.body.destroy();
|
|
349
|
+
const wwwAuthenticate = response.headers["www-authenticate"];
|
|
350
|
+
return { status: response.status, wwwAuthenticate: typeof wwwAuthenticate === "string" ? wwwAuthenticate : null };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function fetchMetadata(
|
|
354
|
+
url: string,
|
|
355
|
+
signal: AbortSignal,
|
|
356
|
+
deps: McpCaptureDeps,
|
|
357
|
+
): Promise<Record<string, unknown> | null> {
|
|
358
|
+
try {
|
|
359
|
+
const { status, json } = await safeJson(
|
|
360
|
+
{ url, signal },
|
|
361
|
+
{ maxResponseBytes: DISCOVERY_MAX_BYTES, limits: deps.limits, deps: deps.http },
|
|
362
|
+
);
|
|
363
|
+
return status >= 200 && status < 300 && json && typeof json === "object" && !Array.isArray(json)
|
|
364
|
+
? (json as Record<string, unknown>)
|
|
365
|
+
: null;
|
|
366
|
+
} catch (thrown) {
|
|
367
|
+
// The step's deadline ends discovery, not just this candidate.
|
|
368
|
+
if (signal.aborted) throw thrown;
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Identifiers are parsed only to be validated, and compared as the strings
|
|
375
|
+
* they are: RFC 9728 §6 and RFC 8414 §4 compare code point by code point,
|
|
376
|
+
* with no normalization (a `/a/../mcp` path, an explicit `:443` or another
|
|
377
|
+
* case is another identifier).
|
|
378
|
+
*
|
|
379
|
+
* A resource identifier (RFC 9728 §1.2) is an https URL with no userinfo and
|
|
380
|
+
* no fragment; a query may be part of it.
|
|
381
|
+
*/
|
|
382
|
+
function isResourceIdentifier(raw: string): boolean {
|
|
383
|
+
return !raw.includes("#") && parseSafeUrl(raw) !== null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** An issuer identifier (RFC 8414 §2) is an https URL with no userinfo, no
|
|
387
|
+
* query and no fragment. */
|
|
388
|
+
function isIssuerIdentifier(raw: string): boolean {
|
|
389
|
+
return !raw.includes("?") && !raw.includes("#") && parseSafeUrl(raw) !== null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** An identifier's authority, path and query as spelled: well-known URLs are
|
|
393
|
+
* built from these, never from a re-serialization. */
|
|
394
|
+
function spelling(raw: string): { origin: string; path: string; query: string } {
|
|
395
|
+
const match = /^(https:\/\/[^/?#]+)([^?#]*)(\?[^#]*)?$/i.exec(raw);
|
|
396
|
+
if (!match) throw new McpCaptureError("the server's address is not an https URL");
|
|
397
|
+
return { origin: match[1]!, path: match[2] ?? "", query: match[3] ?? "" };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* RFC 9728 §3.1: `/.well-known/oauth-protected-resource` between the host
|
|
402
|
+
* and the resource's own path and query, as spelled (a lone slash after the
|
|
403
|
+
* host dropped). Only there: the host's root document describes the host,
|
|
404
|
+
* and §3.3 lets metadata speak only for the identifier its URL was built
|
|
405
|
+
* from.
|
|
406
|
+
*/
|
|
407
|
+
function protectedResourceMetadataUrl(resource: string): string {
|
|
408
|
+
const parts = spelling(resource);
|
|
409
|
+
const path = parts.path === "/" ? "" : parts.path;
|
|
410
|
+
return `${parts.origin}/.well-known/oauth-protected-resource${path}${parts.query}`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* RFC 8414 §3.1, in the order the MCP authorization spec asks: the
|
|
415
|
+
* well-known string between the host and the issuer's path (its terminating
|
|
416
|
+
* slash dropped), then OpenID Connect's places for it.
|
|
417
|
+
*/
|
|
418
|
+
function authorizationServerCandidates(issuer: string): string[] {
|
|
419
|
+
const parts = spelling(issuer);
|
|
420
|
+
const path = parts.path.replace(/\/$/, "");
|
|
421
|
+
if (!path) {
|
|
422
|
+
return [
|
|
423
|
+
`${parts.origin}/.well-known/oauth-authorization-server`,
|
|
424
|
+
`${parts.origin}/.well-known/openid-configuration`,
|
|
425
|
+
];
|
|
426
|
+
}
|
|
427
|
+
return [
|
|
428
|
+
`${parts.origin}/.well-known/oauth-authorization-server${path}`,
|
|
429
|
+
`${parts.origin}/.well-known/openid-configuration${path}`,
|
|
430
|
+
`${parts.origin}${path}/.well-known/openid-configuration`,
|
|
431
|
+
];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function originOf(raw: string): string | null {
|
|
435
|
+
return parseSafeUrl(raw)?.origin ?? null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
interface Discovered {
|
|
439
|
+
issuer: string;
|
|
440
|
+
issRequired: boolean;
|
|
441
|
+
authorizationEndpoint: string;
|
|
442
|
+
tokenEndpoint: string;
|
|
443
|
+
registrationEndpoint: string | null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function discover(
|
|
447
|
+
mcpUrl: string,
|
|
448
|
+
wwwAuthenticate: string | null,
|
|
449
|
+
signal: AbortSignal,
|
|
450
|
+
deps: McpCaptureDeps,
|
|
451
|
+
): Promise<Discovered> {
|
|
452
|
+
if (!isResourceIdentifier(mcpUrl)) throw new McpCaptureError("the server's address is not an https URL");
|
|
453
|
+
const hinted = wwwAuthenticate ? (/resource_metadata="([^"]+)"/i.exec(wwwAuthenticate)?.[1] ?? null) : null;
|
|
454
|
+
// The issuer identifiers the authorization server's metadata may name.
|
|
455
|
+
let issuers: string[] | null = null;
|
|
456
|
+
// The 401's `resource_metadata` (§5.1), then the location §3.1 derives.
|
|
457
|
+
for (const url of [...(hinted ? [hinted] : []), protectedResourceMetadataUrl(mcpUrl)]) {
|
|
458
|
+
const metadata = await fetchMetadata(url, signal, deps);
|
|
459
|
+
// RFC 9728 §3.3: identical, as strings, or another resource's metadata.
|
|
460
|
+
if (!metadata || metadata.resource !== mcpUrl) continue;
|
|
461
|
+
const servers = metadata.authorization_servers;
|
|
462
|
+
if (!Array.isArray(servers) || typeof servers[0] !== "string") continue;
|
|
463
|
+
if (!isIssuerIdentifier(servers[0])) {
|
|
464
|
+
throw new McpCaptureError("the server names an authorization server that is not an issuer identifier");
|
|
465
|
+
}
|
|
466
|
+
issuers = [servers[0]];
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
// A server from before RFC 9728 is its own authorization server: the
|
|
470
|
+
// identifier the metadata URL is built from is its origin, and only that,
|
|
471
|
+
// exactly, may come back as its issuer (RFC 8414 §3.3).
|
|
472
|
+
issuers ??= [new URL(mcpUrl).origin];
|
|
473
|
+
for (const url of authorizationServerCandidates(issuers[0]!)) {
|
|
474
|
+
const metadata = await fetchMetadata(url, signal, deps);
|
|
475
|
+
// RFC 8414 §3.3: identical, as strings, or another server's metadata.
|
|
476
|
+
if (!metadata || typeof metadata.issuer !== "string" || !issuers.includes(metadata.issuer)) continue;
|
|
477
|
+
if (typeof metadata.authorization_endpoint !== "string" || typeof metadata.token_endpoint !== "string") continue;
|
|
478
|
+
const issRequired = metadata.authorization_response_iss_parameter_supported === true;
|
|
479
|
+
const own = originOf(metadata.issuer);
|
|
480
|
+
if (
|
|
481
|
+
!issRequired &&
|
|
482
|
+
(originOf(metadata.authorization_endpoint) !== own || originOf(metadata.token_endpoint) !== own)
|
|
483
|
+
) {
|
|
484
|
+
// Without RFC 9207's `iss` a code could be sent to another server's
|
|
485
|
+
// token endpoint than the one that issued it.
|
|
486
|
+
throw new McpCaptureError("the authorization server's endpoints are not on its own address");
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
issuer: metadata.issuer,
|
|
490
|
+
issRequired,
|
|
491
|
+
authorizationEndpoint: metadata.authorization_endpoint,
|
|
492
|
+
tokenEndpoint: metadata.token_endpoint,
|
|
493
|
+
registrationEndpoint: typeof metadata.registration_endpoint === "string" ? metadata.registration_endpoint : null,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
throw new McpCaptureError("the server asks for a sign-in but publishes no usable OAuth metadata");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Our own failure, safe to show: never a server's words. */
|
|
500
|
+
class McpCaptureError extends Error {}
|
|
501
|
+
|
|
502
|
+
async function register(
|
|
503
|
+
registrationEndpoint: string,
|
|
504
|
+
redirectUri: string,
|
|
505
|
+
signal: AbortSignal,
|
|
506
|
+
deps: McpCaptureDeps,
|
|
507
|
+
): Promise<{ clientId: string; clientSecret: string | null }> {
|
|
508
|
+
const { status, json } = await safeJson(
|
|
509
|
+
{
|
|
510
|
+
url: registrationEndpoint,
|
|
511
|
+
method: "POST",
|
|
512
|
+
headers: { "content-type": "application/json" },
|
|
513
|
+
body: JSON.stringify({
|
|
514
|
+
client_name: "Uai",
|
|
515
|
+
redirect_uris: [redirectUri],
|
|
516
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
517
|
+
response_types: ["code"],
|
|
518
|
+
token_endpoint_auth_method: "none",
|
|
519
|
+
}),
|
|
520
|
+
signal,
|
|
521
|
+
},
|
|
522
|
+
{ maxResponseBytes: DISCOVERY_MAX_BYTES, limits: deps.limits, deps: deps.http },
|
|
523
|
+
);
|
|
524
|
+
const body = json && typeof json === "object" && !Array.isArray(json) ? (json as Record<string, unknown>) : {};
|
|
525
|
+
if (status < 200 || status >= 300 || typeof body.client_id !== "string" || body.client_id.length > 512) {
|
|
526
|
+
throw new McpCaptureError(`dynamic client registration failed (${oauthFailure(body, status)})`);
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
clientId: body.client_id,
|
|
530
|
+
clientSecret: typeof body.client_secret === "string" ? body.client_secret : null,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* A token-endpoint request: a client secret rides HTTP Basic first, then once
|
|
536
|
+
* more in the body for vendors that only read that (as today's runners do).
|
|
537
|
+
*/
|
|
538
|
+
export async function postTokenEndpoint(
|
|
539
|
+
tokenEndpoint: string,
|
|
540
|
+
params: Record<string, string>,
|
|
541
|
+
client: { clientId: string; clientSecret: string | null },
|
|
542
|
+
deps: Pick<McpCaptureDeps, "http" | "limits"> & { signal?: AbortSignal },
|
|
543
|
+
): Promise<{ status: number; json: Record<string, unknown> }> {
|
|
544
|
+
const signal = deps.signal ?? AbortSignal.timeout(OPERATION_DEADLINE_MS);
|
|
545
|
+
const attempt = async (mode: "basic" | "post") => {
|
|
546
|
+
const form = new URLSearchParams(params);
|
|
547
|
+
const headers: Record<string, string> = { "content-type": "application/x-www-form-urlencoded" };
|
|
548
|
+
if (client.clientSecret) {
|
|
549
|
+
if (mode === "basic") {
|
|
550
|
+
headers.authorization = `Basic ${Buffer.from(`${client.clientId}:${client.clientSecret}`).toString("base64")}`;
|
|
551
|
+
} else {
|
|
552
|
+
form.set("client_secret", client.clientSecret);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const { status, json } = await safeJson(
|
|
556
|
+
{ url: tokenEndpoint, method: "POST", headers, body: form.toString(), signal },
|
|
557
|
+
{ maxResponseBytes: 64 * 1024, limits: deps.limits, deps: deps.http },
|
|
558
|
+
);
|
|
559
|
+
const body = json && typeof json === "object" && !Array.isArray(json) ? (json as Record<string, unknown>) : {};
|
|
560
|
+
return { status, json: body };
|
|
561
|
+
};
|
|
562
|
+
const basic = await attempt("basic");
|
|
563
|
+
const ok = basic.status >= 200 && basic.status < 300 && typeof basic.json.access_token === "string";
|
|
564
|
+
if (ok || !client.clientSecret) return basic;
|
|
565
|
+
return attempt("post");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** The scopes a 401's Bearer challenge asks for (RFC 6750 §3), if any. */
|
|
569
|
+
function challengeScopes(wwwAuthenticate: string | null): string[] | null {
|
|
570
|
+
const raw = wwwAuthenticate ? (/\bscope="([^"]*)"/i.exec(wwwAuthenticate)?.[1] ?? null) : null;
|
|
571
|
+
if (raw === null) return null;
|
|
572
|
+
const scopes = raw.split(" ").filter(Boolean);
|
|
573
|
+
return scopes.length > 0 && isScopeList(scopes) ? scopes : null;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** What a token response granted, only as far as it was asked for: a server
|
|
577
|
+
* cannot put anything else into an answer this way. */
|
|
578
|
+
function grantedScopes(raw: unknown, requested: string | null): string[] | null {
|
|
579
|
+
if (requested === null) return null;
|
|
580
|
+
const asked = new Set(requested.split(" "));
|
|
581
|
+
if (typeof raw !== "string") return [...asked];
|
|
582
|
+
return raw.split(" ").filter((scope) => asked.has(scope));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** A step its signal ended: the cloud's cancel, or the step's deadline. */
|
|
586
|
+
function stopped(signal: AbortSignal): AckBody {
|
|
587
|
+
const reason: unknown = signal.reason;
|
|
588
|
+
const timedOut = reason instanceof DOMException && reason.name === "TimeoutError";
|
|
589
|
+
return error(timedOut ? "the connection took too long to set up" : "cancelled");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function failed(thrown: unknown): AckBody {
|
|
593
|
+
if (thrown instanceof SafeHttpRefusal) return error(`refused: ${thrown.message}`);
|
|
594
|
+
if (thrown instanceof McpCaptureError) return error(thrown.message);
|
|
595
|
+
// Anything else may carry text from elsewhere: say only that it failed.
|
|
596
|
+
return error("the connection could not be completed");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function mcpFlow(raw: unknown): ParsedCaptureFlow | null {
|
|
600
|
+
const flow = parseCaptureFlow(raw);
|
|
601
|
+
return flow && flow.location.class === "mcp" ? flow : null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Probe an MCP server for a connection being added. */
|
|
605
|
+
export async function probeMcpCapture(frame: McpCaptureProbeFrame, deps: McpCaptureDeps): Promise<AckBody> {
|
|
606
|
+
const flow = mcpFlow(frame.flow);
|
|
607
|
+
if (!flow) return error("this capture is not an MCP connection's");
|
|
608
|
+
const now = deps.now ?? Date.now;
|
|
609
|
+
const step = beginStep(frame.flow, frame.state, deps.deadlineMs ?? OPERATION_DEADLINE_MS, now());
|
|
610
|
+
if (typeof step === "string") return refusedStep(step);
|
|
611
|
+
const { signal } = step;
|
|
612
|
+
try {
|
|
613
|
+
if (frame.header) {
|
|
614
|
+
if (flow.brokerParameter !== null) return error("a static header does not rotate; it has no broker part");
|
|
615
|
+
const answer = await initialize(frame.url, frame.header, signal, deps);
|
|
616
|
+
if (answer.status === 401) return error("the server rejected the header (401)");
|
|
617
|
+
if (answer.status === 403) {
|
|
618
|
+
return error("the server accepted the header but refused the request (403): it likely lacks permission");
|
|
619
|
+
}
|
|
620
|
+
if (answer.status < 200 || answer.status >= 300) return error(`unexpected response (${answer.status})`);
|
|
621
|
+
if (signal.aborted) return stopped(signal);
|
|
622
|
+
const capture = await writeCapture(
|
|
623
|
+
deps.store,
|
|
624
|
+
flow,
|
|
625
|
+
{
|
|
626
|
+
lease: serializeLeasedValue({
|
|
627
|
+
v: 1,
|
|
628
|
+
part: "lease",
|
|
629
|
+
location: flow.location,
|
|
630
|
+
class: "mcp",
|
|
631
|
+
authKind: "token",
|
|
632
|
+
header: frame.header,
|
|
633
|
+
}),
|
|
634
|
+
broker: null,
|
|
635
|
+
expiresAt: null,
|
|
636
|
+
},
|
|
637
|
+
{ signal },
|
|
638
|
+
);
|
|
639
|
+
return { outcome: "token", capture, authorizeUrl: null, scopes: null, error: null };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const answer = await initialize(frame.url, null, signal, deps);
|
|
643
|
+
if (answer.status >= 200 && answer.status < 300) {
|
|
644
|
+
return { outcome: "none", capture: null, authorizeUrl: null, scopes: null, error: null };
|
|
645
|
+
}
|
|
646
|
+
if (answer.status !== 401 && answer.status !== 403) {
|
|
647
|
+
return error(`not an MCP server? initialize answered ${answer.status}`);
|
|
648
|
+
}
|
|
649
|
+
if (flow.brokerParameter === null) return error("an OAuth sign-in needs a broker part to keep its refresh token");
|
|
650
|
+
const found = await discover(frame.url, answer.wwwAuthenticate, signal, deps);
|
|
651
|
+
let client = { clientId: frame.clientId, clientSecret: frame.clientSecret };
|
|
652
|
+
if (!client.clientId) {
|
|
653
|
+
if (!found.registrationEndpoint) {
|
|
654
|
+
return error("the authorization server cannot register a client: enter a client id and secret");
|
|
655
|
+
}
|
|
656
|
+
if (originOf(found.registrationEndpoint) !== originOf(found.issuer) && !found.issRequired) {
|
|
657
|
+
return error("the authorization server's endpoints are not on its own address");
|
|
658
|
+
}
|
|
659
|
+
const registered = await register(found.registrationEndpoint, frame.redirectUri, signal, deps);
|
|
660
|
+
client = { clientId: registered.clientId, clientSecret: registered.clientSecret };
|
|
661
|
+
}
|
|
662
|
+
let authorize: URL;
|
|
663
|
+
try {
|
|
664
|
+
authorize = new URL(found.authorizationEndpoint);
|
|
665
|
+
} catch {
|
|
666
|
+
return error("the server's sign-in page is not an address");
|
|
667
|
+
}
|
|
668
|
+
// Only an https page on a public address is ever handed to the browser:
|
|
669
|
+
// a `javascript:` one would run in Uai's origin, a private one would aim
|
|
670
|
+
// the person's browser at their own network.
|
|
671
|
+
if (authorize.protocol !== "https:" || authorize.username !== "" || authorize.password !== "") {
|
|
672
|
+
return error("the server's sign-in page is not an https address");
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
await vetPublicUrl(authorize.toString(), deps.limits, deps.http, signal);
|
|
676
|
+
} catch {
|
|
677
|
+
if (signal.aborted) return stopped(signal);
|
|
678
|
+
return error("the server's sign-in page is not on a public address");
|
|
679
|
+
}
|
|
680
|
+
// The connection's own needs, never the server's whole capability list.
|
|
681
|
+
const scopes = frame.scopes ?? challengeScopes(answer.wwwAuthenticate);
|
|
682
|
+
const verifier = b64url(randomBytes(32));
|
|
683
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
684
|
+
authorize.searchParams.set("response_type", "code");
|
|
685
|
+
authorize.searchParams.set("client_id", client.clientId!);
|
|
686
|
+
authorize.searchParams.set("redirect_uri", frame.redirectUri);
|
|
687
|
+
authorize.searchParams.set("state", frame.state);
|
|
688
|
+
authorize.searchParams.set("code_challenge", challenge);
|
|
689
|
+
authorize.searchParams.set("code_challenge_method", "S256");
|
|
690
|
+
authorize.searchParams.set("resource", frame.url);
|
|
691
|
+
if (scopes?.length) authorize.searchParams.set("scope", scopes.join(" "));
|
|
692
|
+
// A cancel that came while this step ran leaves nothing waiting.
|
|
693
|
+
if (signal.aborted) return stopped(signal);
|
|
694
|
+
rememberPending(flowKey(flow), {
|
|
695
|
+
flow: { ...frame.flow },
|
|
696
|
+
state: frame.state,
|
|
697
|
+
verifier,
|
|
698
|
+
clientId: client.clientId!,
|
|
699
|
+
clientSecret: client.clientSecret,
|
|
700
|
+
issuer: found.issuer,
|
|
701
|
+
issRequired: found.issRequired,
|
|
702
|
+
tokenEndpoint: found.tokenEndpoint,
|
|
703
|
+
redirectUri: frame.redirectUri,
|
|
704
|
+
resource: frame.url,
|
|
705
|
+
scopes: scopes?.length ? scopes.join(" ") : null,
|
|
706
|
+
expiresAt: now() + PENDING_TTL_MS,
|
|
707
|
+
});
|
|
708
|
+
return {
|
|
709
|
+
outcome: "auth_required",
|
|
710
|
+
capture: null,
|
|
711
|
+
authorizeUrl: authorize.toString(),
|
|
712
|
+
scopes: scopes?.length ? scopes : null,
|
|
713
|
+
error: null,
|
|
714
|
+
};
|
|
715
|
+
} catch (thrown) {
|
|
716
|
+
return signal.aborted ? stopped(signal) : failed(thrown);
|
|
717
|
+
} finally {
|
|
718
|
+
step.end();
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** The authorization server redirected back: exchange the code and write
|
|
723
|
+
* the revision, broker-only part first. */
|
|
724
|
+
export async function completeMcpCapture(frame: McpCaptureCompleteFrame, deps: McpCaptureDeps): Promise<AckBody> {
|
|
725
|
+
const key = flowKey(frame.flow);
|
|
726
|
+
const pending = pendingSignIns.get(key);
|
|
727
|
+
// Taken once, whatever happens next.
|
|
728
|
+
forgetPending(key);
|
|
729
|
+
const now = deps.now ?? Date.now;
|
|
730
|
+
if (!pending || pending.expiresAt <= now()) {
|
|
731
|
+
return error("the sign-in box lost this sign-in (it restarted, or it took too long): connect again");
|
|
732
|
+
}
|
|
733
|
+
if (pending.state !== frame.state || !sameFlow(pending.flow, frame.flow)) {
|
|
734
|
+
return error("this callback is not the sign-in that was started");
|
|
735
|
+
}
|
|
736
|
+
// RFC 9207 §2.4: `iss` is compared to the issuer as a simple string; a
|
|
737
|
+
// redirect naming another issuer is another server's code.
|
|
738
|
+
if ((frame.iss !== null || pending.issRequired) && frame.iss !== pending.issuer) {
|
|
739
|
+
return error("this callback came from another authorization server than the sign-in's");
|
|
740
|
+
}
|
|
741
|
+
const flow = mcpFlow(pending.flow);
|
|
742
|
+
if (!flow || flow.brokerParameter === null) return error("this capture is not an MCP sign-in's");
|
|
743
|
+
const step = beginStep(pending.flow, pending.state, deps.deadlineMs ?? OPERATION_DEADLINE_MS, now());
|
|
744
|
+
if (typeof step === "string") return refusedStep(step);
|
|
745
|
+
const { signal } = step;
|
|
746
|
+
try {
|
|
747
|
+
const { status, json } = await postTokenEndpoint(
|
|
748
|
+
pending.tokenEndpoint,
|
|
749
|
+
{
|
|
750
|
+
grant_type: "authorization_code",
|
|
751
|
+
code: frame.code,
|
|
752
|
+
redirect_uri: pending.redirectUri,
|
|
753
|
+
client_id: pending.clientId,
|
|
754
|
+
code_verifier: pending.verifier,
|
|
755
|
+
resource: pending.resource,
|
|
756
|
+
},
|
|
757
|
+
{ clientId: pending.clientId, clientSecret: pending.clientSecret },
|
|
758
|
+
{ ...deps, signal },
|
|
759
|
+
);
|
|
760
|
+
if (status < 200 || status >= 300 || typeof json.access_token !== "string" || json.access_token.length > 4096) {
|
|
761
|
+
return error(`the token exchange failed (${oauthFailure(json, status)})`);
|
|
762
|
+
}
|
|
763
|
+
const expiresAt = typeof json.expires_in === "number" ? now() + json.expires_in * 1000 : null;
|
|
764
|
+
const scopes = grantedScopes(json.scope, pending.scopes);
|
|
765
|
+
const broker: BrokerMcpValue = {
|
|
766
|
+
v: 1,
|
|
767
|
+
part: "broker",
|
|
768
|
+
location: flow.location,
|
|
769
|
+
class: "mcp",
|
|
770
|
+
lane: MCP_OAUTH_LANE,
|
|
771
|
+
tokenEndpoint: pending.tokenEndpoint,
|
|
772
|
+
clientId: pending.clientId,
|
|
773
|
+
clientSecret: pending.clientSecret,
|
|
774
|
+
resource: pending.resource,
|
|
775
|
+
accessToken: json.access_token,
|
|
776
|
+
refreshToken: typeof json.refresh_token === "string" ? json.refresh_token : null,
|
|
777
|
+
scopes: pending.scopes,
|
|
778
|
+
refresh: { ...emptyRefreshMeta(MCP_OAUTH_LANE), expiresAt, refreshedAt: now() },
|
|
779
|
+
};
|
|
780
|
+
if (signal.aborted) return stopped(signal);
|
|
781
|
+
const capture: CaptureResultWire = await writeCapture(
|
|
782
|
+
deps.store,
|
|
783
|
+
flow,
|
|
784
|
+
{
|
|
785
|
+
broker: JSON.stringify(broker),
|
|
786
|
+
lease: serializeLeasedValue({
|
|
787
|
+
v: 1,
|
|
788
|
+
part: "lease",
|
|
789
|
+
location: flow.location,
|
|
790
|
+
class: "mcp",
|
|
791
|
+
authKind: "oauth",
|
|
792
|
+
accessToken: json.access_token,
|
|
793
|
+
tokenType: "Bearer",
|
|
794
|
+
expiresAt,
|
|
795
|
+
}),
|
|
796
|
+
expiresAt,
|
|
797
|
+
},
|
|
798
|
+
{ signal },
|
|
799
|
+
);
|
|
800
|
+
return { outcome: "connected", capture, authorizeUrl: null, scopes, error: null };
|
|
801
|
+
} catch (thrown) {
|
|
802
|
+
return signal.aborted ? stopped(signal) : failed(thrown);
|
|
803
|
+
} finally {
|
|
804
|
+
step.end();
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** A stored MCP broker part, exactly, for the revision at `expected`. */
|
|
809
|
+
export function parseBrokerMcpValue(raw: string, expected: CredentialLocation): BrokerMcpValue | null {
|
|
810
|
+
let parsed: unknown;
|
|
811
|
+
try {
|
|
812
|
+
parsed = JSON.parse(raw);
|
|
813
|
+
} catch {
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
817
|
+
const o = parsed as Record<string, unknown>;
|
|
818
|
+
const location = o.location as Record<string, unknown> | null;
|
|
819
|
+
const refresh = refreshMetaFrom(o.refresh);
|
|
820
|
+
if (
|
|
821
|
+
o.v !== 1 ||
|
|
822
|
+
o.part !== "broker" ||
|
|
823
|
+
o.class !== "mcp" ||
|
|
824
|
+
o.lane !== MCP_OAUTH_LANE ||
|
|
825
|
+
expected.class !== "mcp" ||
|
|
826
|
+
!location ||
|
|
827
|
+
typeof location !== "object" ||
|
|
828
|
+
location.class !== expected.class ||
|
|
829
|
+
location.scope !== expected.scope ||
|
|
830
|
+
location.ownerId !== expected.ownerId ||
|
|
831
|
+
location.credentialId !== expected.credentialId ||
|
|
832
|
+
location.revision !== expected.revision ||
|
|
833
|
+
typeof o.tokenEndpoint !== "string" ||
|
|
834
|
+
typeof o.clientId !== "string" ||
|
|
835
|
+
!(o.clientSecret === null || typeof o.clientSecret === "string") ||
|
|
836
|
+
typeof o.resource !== "string" ||
|
|
837
|
+
typeof o.accessToken !== "string" ||
|
|
838
|
+
!(o.refreshToken === null || typeof o.refreshToken === "string") ||
|
|
839
|
+
!(o.scopes === null || typeof o.scopes === "string") ||
|
|
840
|
+
!refresh ||
|
|
841
|
+
refresh.lane !== MCP_OAUTH_LANE
|
|
842
|
+
) {
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
v: 1,
|
|
847
|
+
part: "broker",
|
|
848
|
+
location: expected,
|
|
849
|
+
class: "mcp",
|
|
850
|
+
lane: MCP_OAUTH_LANE,
|
|
851
|
+
tokenEndpoint: o.tokenEndpoint,
|
|
852
|
+
clientId: o.clientId,
|
|
853
|
+
clientSecret: o.clientSecret,
|
|
854
|
+
resource: o.resource,
|
|
855
|
+
accessToken: o.accessToken,
|
|
856
|
+
refreshToken: o.refreshToken,
|
|
857
|
+
scopes: o.scopes,
|
|
858
|
+
refresh,
|
|
859
|
+
};
|
|
860
|
+
}
|