@specific.dev/spectest 0.55.0 → 0.56.1
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/daemon.js +10 -0
- package/dist/harness/raw-fetch.d.ts +7 -0
- package/dist/harness/raw-fetch.js +33 -0
- package/dist/ids.d.ts +9 -0
- package/dist/ids.js +11 -1
- package/dist/index.d.ts +43 -0
- package/dist/index.js +4 -0
- package/dist/mcp-auth.d.ts +176 -0
- package/dist/mcp-auth.js +455 -0
- package/dist/mcp-transport.d.ts +130 -0
- package/dist/mcp-transport.js +337 -0
- package/dist/mcp.d.ts +246 -0
- package/dist/mcp.js +1060 -0
- package/dist/recorder.d.ts +5 -0
- package/package.json +1 -1
- package/src/daemon.ts +10 -0
- package/src/harness/raw-fetch.ts +36 -0
- package/src/ids.ts +12 -1
- package/src/index.ts +69 -0
- package/src/mcp-auth.ts +626 -0
- package/src/mcp-transport.ts +434 -0
- package/src/mcp.test.ts +94 -0
- package/src/mcp.ts +1435 -0
- package/src/recorder.ts +5 -2
package/src/mcp-auth.ts
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
// OAuth 2.1 for the MCP client (`mcp.ts`).
|
|
2
|
+
//
|
|
3
|
+
// An MCP server is an OAuth *resource server*. It rejects an unauthorized
|
|
4
|
+
// request with `401` and a `WWW-Authenticate` header, and that header is
|
|
5
|
+
// the entry point of this file. From there the flow is:
|
|
6
|
+
//
|
|
7
|
+
// 1. Read the protected-resource metadata (RFC 9728). It names the
|
|
8
|
+
// authorization server and the canonical `resource` identifier.
|
|
9
|
+
// 2. Read the authorization-server metadata (RFC 8414). Note the
|
|
10
|
+
// path-insertion rule: an issuer with a path is discovered at
|
|
11
|
+
// `https://host/.well-known/oauth-authorization-server/<path>`, not
|
|
12
|
+
// at `<issuer>/.well-known/...`.
|
|
13
|
+
// 3. Register the client dynamically (RFC 7591) when the server offers
|
|
14
|
+
// it. This is also what lets us declare a redirect URI on a port we
|
|
15
|
+
// only chose a moment ago.
|
|
16
|
+
// 4. Authorization code with PKCE (S256, mandatory) and the `resource`
|
|
17
|
+
// parameter (RFC 8707, required by MCP — it binds the token to this
|
|
18
|
+
// one server).
|
|
19
|
+
// 5. Exchange the code, then refresh the token when it expires.
|
|
20
|
+
//
|
|
21
|
+
// The SDK owns all of that. It does NOT own the browser. A test drives the
|
|
22
|
+
// login and the consent screen itself, because that screen belongs to the
|
|
23
|
+
// application under test and a framework that clicks it by guesswork turns
|
|
24
|
+
// a broken consent page into a passing test. See `mcp.ts` for the seam.
|
|
25
|
+
//
|
|
26
|
+
// WHY LOOPBACK. The default redirect URI is `http://127.0.0.1:<port>/callback`
|
|
27
|
+
// on an ephemeral port. That is what a desktop MCP client does — it is
|
|
28
|
+
// OAuth 2.0 for Native Apps (RFC 8252 §7.3), and §8.3 prefers the IP
|
|
29
|
+
// literal over the name `localhost`. It works here because Chromium is the
|
|
30
|
+
// guest's own browser, in the same VM and the same network namespace as
|
|
31
|
+
// this daemon, so the browser's loopback IS our loopback. The
|
|
32
|
+
// authorization server never touches the address: a redirect is a browser
|
|
33
|
+
// navigation, not a server-to-server call.
|
|
34
|
+
//
|
|
35
|
+
// LANDMINE — GoTrue (Supabase Auth) refuses a plain-http redirect URI
|
|
36
|
+
// unless it is localhost, and it recognises the NAME. So a Supabase-backed
|
|
37
|
+
// project must use `localhost`, not `127.0.0.1`. `loopbackHost()` below
|
|
38
|
+
// keeps the RFC's preference as the default and the name as the documented
|
|
39
|
+
// override; do not "correct" one into the other without testing both.
|
|
40
|
+
|
|
41
|
+
import { secureRandomBytes } from "./ids.js";
|
|
42
|
+
import { rawFetch } from "./harness/raw-fetch.js";
|
|
43
|
+
import { createHash } from "node:crypto";
|
|
44
|
+
|
|
45
|
+
/** The redirect host. RFC 8252 §8.3 prefers the IP literal. Some
|
|
46
|
+
* authorization servers only special-case the name — see the landmine at
|
|
47
|
+
* the top of this file. */
|
|
48
|
+
export type LoopbackHost = "127.0.0.1" | "localhost";
|
|
49
|
+
|
|
50
|
+
/** Protected-resource metadata (RFC 9728), the subset MCP uses. */
|
|
51
|
+
export interface ProtectedResourceMetadata {
|
|
52
|
+
resource?: string;
|
|
53
|
+
authorization_servers?: string[];
|
|
54
|
+
scopes_supported?: string[];
|
|
55
|
+
bearer_methods_supported?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Authorization-server metadata (RFC 8414), the subset we need. */
|
|
59
|
+
export interface AuthServerMetadata {
|
|
60
|
+
issuer: string;
|
|
61
|
+
authorization_endpoint: string;
|
|
62
|
+
token_endpoint: string;
|
|
63
|
+
registration_endpoint?: string;
|
|
64
|
+
scopes_supported?: string[];
|
|
65
|
+
code_challenge_methods_supported?: string[];
|
|
66
|
+
grant_types_supported?: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What the client ended up with. Handed to the test, so it can assert on
|
|
70
|
+
* the grant and reuse the token elsewhere. */
|
|
71
|
+
export interface McpIdentity {
|
|
72
|
+
/** The bearer token. Redacted in every recorded event, never in this
|
|
73
|
+
* value — a test may need it to call the same API directly. */
|
|
74
|
+
accessToken: string;
|
|
75
|
+
refreshToken?: string;
|
|
76
|
+
tokenType: string;
|
|
77
|
+
/** What the server GRANTED, which is not always what was asked for. */
|
|
78
|
+
scopes: string[];
|
|
79
|
+
/** The RFC 8707 resource the token is bound to. */
|
|
80
|
+
resource?: string;
|
|
81
|
+
clientId: string;
|
|
82
|
+
issuer: string;
|
|
83
|
+
/** The redirect URI this grant was issued for. Kept so a test can prove
|
|
84
|
+
* which kind of client it just was — a loopback one, by default. */
|
|
85
|
+
redirectUri: string;
|
|
86
|
+
/** Where to spend the refresh token. Carried on the identity because a
|
|
87
|
+
* refresh usually happens in a forked test that never ran discovery,
|
|
88
|
+
* and an issuer's token endpoint is not derivable from its URL. */
|
|
89
|
+
tokenEndpoint: string;
|
|
90
|
+
/** Epoch milliseconds, when the server reported `expires_in`. */
|
|
91
|
+
expiresAt?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The user (or the authorization server) refused the grant. */
|
|
95
|
+
export class McpAuthDeniedError extends Error {
|
|
96
|
+
readonly code: string;
|
|
97
|
+
readonly description?: string;
|
|
98
|
+
|
|
99
|
+
constructor(code: string, description?: string) {
|
|
100
|
+
super(`authorization denied: ${code}${description ? ` — ${description}` : ""}`);
|
|
101
|
+
this.name = "McpAuthDeniedError";
|
|
102
|
+
this.code = code;
|
|
103
|
+
this.description = description;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface AuthorizeOptions {
|
|
108
|
+
/** Skip dynamic registration and use a pre-registered client. */
|
|
109
|
+
clientId?: string;
|
|
110
|
+
/** For a confidential client. Sent with the token request. */
|
|
111
|
+
clientSecret?: string;
|
|
112
|
+
/**
|
|
113
|
+
* Use this redirect URI instead of a loopback listener. Nothing is
|
|
114
|
+
* served for it — the browser lands somewhere this daemon does not own,
|
|
115
|
+
* so the test must hand the landed URL back:
|
|
116
|
+
* `await auth.complete({ url: page.url() })`.
|
|
117
|
+
*/
|
|
118
|
+
redirectUri?: string;
|
|
119
|
+
/**
|
|
120
|
+
* Escape hatch. Scopes normally come from the protected-resource
|
|
121
|
+
* metadata, because that is what a real MCP client does — it does not
|
|
122
|
+
* ask its user which scopes to request. Set this only for a server that
|
|
123
|
+
* advertises none and still demands one.
|
|
124
|
+
*/
|
|
125
|
+
scopes?: string[];
|
|
126
|
+
/** Client name presented at dynamic registration and, usually, on the
|
|
127
|
+
* consent screen. */
|
|
128
|
+
clientName?: string;
|
|
129
|
+
/** Override the RFC 8707 resource. Defaults to the metadata's
|
|
130
|
+
* `resource`, else the MCP server URL. */
|
|
131
|
+
resource?: string;
|
|
132
|
+
/** Which loopback host to register. Defaults to `127.0.0.1`. */
|
|
133
|
+
loopbackHost?: LoopbackHost;
|
|
134
|
+
/** Budget for {@link Authorization.complete}. Default 120 s — a human
|
|
135
|
+
* flow driven by browser steps is slower than an HTTP call. */
|
|
136
|
+
timeoutMs?: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Everything discovery found, so a test can assert on it. */
|
|
140
|
+
export interface AuthorizationServerInfo {
|
|
141
|
+
issuer: string;
|
|
142
|
+
authorizationEndpoint: string;
|
|
143
|
+
tokenEndpoint: string;
|
|
144
|
+
registrationEndpoint?: string;
|
|
145
|
+
/** True when this client registered itself for this flow. */
|
|
146
|
+
dynamicallyRegistered: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const DEFAULT_COMPLETE_TIMEOUT_MS = 120_000;
|
|
150
|
+
|
|
151
|
+
/** A minimal loopback HTTP server. Declared structurally so this file does
|
|
152
|
+
* not need Bun's types at the call site. */
|
|
153
|
+
interface LoopbackServer {
|
|
154
|
+
port: number;
|
|
155
|
+
stop(closeActiveConnections?: boolean): void;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One authorization attempt, in progress.
|
|
160
|
+
*
|
|
161
|
+
* `authorize()` has already done discovery, registration and PKCE, and has
|
|
162
|
+
* bound the loopback listener. All that is left is the part with a human
|
|
163
|
+
* in it: the test navigates a browser to {@link url}, and then calls
|
|
164
|
+
* {@link complete}.
|
|
165
|
+
*/
|
|
166
|
+
export class Authorization {
|
|
167
|
+
/** Send the browser here. */
|
|
168
|
+
readonly url: string;
|
|
169
|
+
readonly redirectUri: string;
|
|
170
|
+
readonly state: string;
|
|
171
|
+
readonly clientId: string;
|
|
172
|
+
readonly server: AuthorizationServerInfo;
|
|
173
|
+
/** Scopes requested, which may be empty — see {@link AuthorizeOptions.scopes}. */
|
|
174
|
+
readonly scopes: string[];
|
|
175
|
+
readonly resource?: string;
|
|
176
|
+
|
|
177
|
+
private readonly verifier: string;
|
|
178
|
+
private readonly clientSecret?: string;
|
|
179
|
+
private readonly timeoutMs: number;
|
|
180
|
+
private readonly listener?: LoopbackListener;
|
|
181
|
+
private settled = false;
|
|
182
|
+
|
|
183
|
+
constructor(init: {
|
|
184
|
+
url: string;
|
|
185
|
+
redirectUri: string;
|
|
186
|
+
state: string;
|
|
187
|
+
clientId: string;
|
|
188
|
+
clientSecret?: string;
|
|
189
|
+
server: AuthorizationServerInfo;
|
|
190
|
+
scopes: string[];
|
|
191
|
+
resource?: string;
|
|
192
|
+
verifier: string;
|
|
193
|
+
timeoutMs: number;
|
|
194
|
+
listener?: LoopbackListener;
|
|
195
|
+
}) {
|
|
196
|
+
this.url = init.url;
|
|
197
|
+
this.redirectUri = init.redirectUri;
|
|
198
|
+
this.state = init.state;
|
|
199
|
+
this.clientId = init.clientId;
|
|
200
|
+
this.clientSecret = init.clientSecret;
|
|
201
|
+
this.server = init.server;
|
|
202
|
+
this.scopes = init.scopes;
|
|
203
|
+
this.resource = init.resource;
|
|
204
|
+
this.verifier = init.verifier;
|
|
205
|
+
this.timeoutMs = init.timeoutMs;
|
|
206
|
+
this.listener = init.listener;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Wait for the redirect, then exchange the code for a token.
|
|
211
|
+
*
|
|
212
|
+
* With the default loopback redirect there is nothing to pass: the
|
|
213
|
+
* listener already holds the code, and this returns at once when the
|
|
214
|
+
* browser has landed. Pass `url` when the flow used a redirect URI this
|
|
215
|
+
* daemon does not serve — hand back where the browser ended up
|
|
216
|
+
* (`page.url()`).
|
|
217
|
+
*/
|
|
218
|
+
async complete(opts?: { url?: string; timeoutMs?: number }): Promise<McpIdentity> {
|
|
219
|
+
try {
|
|
220
|
+
const params = opts?.url
|
|
221
|
+
? new URL(opts.url).searchParams
|
|
222
|
+
: await this.waitForRedirect(opts?.timeoutMs ?? this.timeoutMs);
|
|
223
|
+
|
|
224
|
+
const error = params.get("error");
|
|
225
|
+
if (error) {
|
|
226
|
+
throw new McpAuthDeniedError(error, params.get("error_description") ?? undefined);
|
|
227
|
+
}
|
|
228
|
+
const returnedState = params.get("state");
|
|
229
|
+
if (returnedState !== this.state) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`authorization state mismatch: expected ${this.state}, got ${returnedState ?? "none"}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const code = params.get("code");
|
|
235
|
+
if (!code) throw new Error("the redirect carried no authorization code");
|
|
236
|
+
|
|
237
|
+
return await this.exchange(code);
|
|
238
|
+
} finally {
|
|
239
|
+
this.settled = true;
|
|
240
|
+
this.listener?.stop();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Release the loopback port without finishing the flow. */
|
|
245
|
+
cancel(): void {
|
|
246
|
+
if (this.settled) return;
|
|
247
|
+
this.settled = true;
|
|
248
|
+
this.listener?.stop();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private async waitForRedirect(timeoutMs: number): Promise<URLSearchParams> {
|
|
252
|
+
if (!this.listener) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
"this authorization used a custom redirectUri, which spectest does not serve. " +
|
|
255
|
+
"Pass where the browser landed: await auth.complete({ url: page.url() })",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
return this.listener.wait(timeoutMs);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async exchange(code: string): Promise<McpIdentity> {
|
|
262
|
+
const body = new URLSearchParams({
|
|
263
|
+
grant_type: "authorization_code",
|
|
264
|
+
code,
|
|
265
|
+
redirect_uri: this.redirectUri,
|
|
266
|
+
client_id: this.clientId,
|
|
267
|
+
code_verifier: this.verifier,
|
|
268
|
+
});
|
|
269
|
+
// RFC 8707. MCP requires it on the token request too, not only on the
|
|
270
|
+
// authorize request — it is what binds the token to this one server.
|
|
271
|
+
if (this.resource) body.set("resource", this.resource);
|
|
272
|
+
if (this.clientSecret) body.set("client_secret", this.clientSecret);
|
|
273
|
+
|
|
274
|
+
const token = await postToken(this.server.tokenEndpoint, body);
|
|
275
|
+
return {
|
|
276
|
+
accessToken: token.access_token,
|
|
277
|
+
refreshToken: token.refresh_token,
|
|
278
|
+
tokenType: token.token_type ?? "Bearer",
|
|
279
|
+
scopes: splitScope(token.scope) ?? this.scopes,
|
|
280
|
+
resource: this.resource,
|
|
281
|
+
clientId: this.clientId,
|
|
282
|
+
issuer: this.server.issuer,
|
|
283
|
+
redirectUri: this.redirectUri,
|
|
284
|
+
tokenEndpoint: this.server.tokenEndpoint,
|
|
285
|
+
expiresAt: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Do everything up to the browser: discovery, registration, PKCE, and the
|
|
292
|
+
* loopback listener. Returns the URL to send the user to.
|
|
293
|
+
*/
|
|
294
|
+
export async function authorize(
|
|
295
|
+
serverUrl: string,
|
|
296
|
+
resourceMetadataUrl: string | undefined,
|
|
297
|
+
opts: AuthorizeOptions = {},
|
|
298
|
+
): Promise<Authorization> {
|
|
299
|
+
const prm = await fetchProtectedResourceMetadata(serverUrl, resourceMetadataUrl);
|
|
300
|
+
const issuer = prm?.authorization_servers?.[0] ?? new URL(serverUrl).origin;
|
|
301
|
+
const metadata = await fetchAuthServerMetadata(issuer);
|
|
302
|
+
|
|
303
|
+
const resource = opts.resource ?? prm?.resource ?? canonicalResource(serverUrl);
|
|
304
|
+
// A real MCP client does not ask its user for scopes: it uses what the
|
|
305
|
+
// resource advertises, and otherwise sends none and lets the server
|
|
306
|
+
// decide.
|
|
307
|
+
const scopes = opts.scopes ?? prm?.scopes_supported ?? [];
|
|
308
|
+
|
|
309
|
+
let listener: LoopbackListener | undefined;
|
|
310
|
+
let redirectUri = opts.redirectUri;
|
|
311
|
+
if (!redirectUri) {
|
|
312
|
+
listener = await startLoopbackListener(opts.loopbackHost ?? "127.0.0.1");
|
|
313
|
+
redirectUri = listener.redirectUri;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let clientId = opts.clientId;
|
|
317
|
+
let clientSecret = opts.clientSecret;
|
|
318
|
+
let dynamicallyRegistered = false;
|
|
319
|
+
if (!clientId) {
|
|
320
|
+
if (!metadata.registration_endpoint) {
|
|
321
|
+
listener?.stop();
|
|
322
|
+
throw new Error(
|
|
323
|
+
`${issuer} does not offer dynamic client registration. ` +
|
|
324
|
+
"Pass an existing client: mcp.authorize({ clientId, redirectUri }).",
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const registered = await registerClient(metadata.registration_endpoint, {
|
|
328
|
+
redirectUri,
|
|
329
|
+
clientName: opts.clientName ?? "spectest",
|
|
330
|
+
scopes,
|
|
331
|
+
});
|
|
332
|
+
clientId = registered.client_id;
|
|
333
|
+
clientSecret = registered.client_secret;
|
|
334
|
+
dynamicallyRegistered = true;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const verifier = base64url(secureRandomBytes(32));
|
|
338
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
339
|
+
const state = base64url(secureRandomBytes(16));
|
|
340
|
+
|
|
341
|
+
const url = new URL(metadata.authorization_endpoint);
|
|
342
|
+
url.searchParams.set("response_type", "code");
|
|
343
|
+
url.searchParams.set("client_id", clientId);
|
|
344
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
345
|
+
url.searchParams.set("state", state);
|
|
346
|
+
url.searchParams.set("code_challenge", challenge);
|
|
347
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
348
|
+
if (resource) url.searchParams.set("resource", resource);
|
|
349
|
+
if (scopes.length > 0) url.searchParams.set("scope", scopes.join(" "));
|
|
350
|
+
|
|
351
|
+
return new Authorization({
|
|
352
|
+
url: url.toString(),
|
|
353
|
+
redirectUri,
|
|
354
|
+
state,
|
|
355
|
+
clientId,
|
|
356
|
+
clientSecret,
|
|
357
|
+
server: {
|
|
358
|
+
issuer: metadata.issuer,
|
|
359
|
+
authorizationEndpoint: metadata.authorization_endpoint,
|
|
360
|
+
tokenEndpoint: metadata.token_endpoint,
|
|
361
|
+
registrationEndpoint: metadata.registration_endpoint,
|
|
362
|
+
dynamicallyRegistered,
|
|
363
|
+
},
|
|
364
|
+
scopes,
|
|
365
|
+
resource,
|
|
366
|
+
verifier,
|
|
367
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_COMPLETE_TIMEOUT_MS,
|
|
368
|
+
listener,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Exchange a refresh token for a new access token. Returns `undefined`
|
|
373
|
+
* when the identity has no refresh token to spend. */
|
|
374
|
+
export async function refreshIdentity(
|
|
375
|
+
identity: McpIdentity,
|
|
376
|
+
clientSecret?: string,
|
|
377
|
+
): Promise<McpIdentity | undefined> {
|
|
378
|
+
if (!identity.refreshToken) return undefined;
|
|
379
|
+
const body = new URLSearchParams({
|
|
380
|
+
grant_type: "refresh_token",
|
|
381
|
+
refresh_token: identity.refreshToken,
|
|
382
|
+
client_id: identity.clientId,
|
|
383
|
+
});
|
|
384
|
+
if (identity.resource) body.set("resource", identity.resource);
|
|
385
|
+
if (clientSecret) body.set("client_secret", clientSecret);
|
|
386
|
+
|
|
387
|
+
const token = await postToken(identity.tokenEndpoint, body);
|
|
388
|
+
return {
|
|
389
|
+
...identity,
|
|
390
|
+
accessToken: token.access_token,
|
|
391
|
+
// A server that rotates refresh tokens returns a new one; one that
|
|
392
|
+
// does not expects the old one to be reused.
|
|
393
|
+
refreshToken: token.refresh_token ?? identity.refreshToken,
|
|
394
|
+
scopes: splitScope(token.scope) ?? identity.scopes,
|
|
395
|
+
expiresAt: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
interface TokenResponse {
|
|
400
|
+
access_token: string;
|
|
401
|
+
token_type?: string;
|
|
402
|
+
expires_in?: number;
|
|
403
|
+
refresh_token?: string;
|
|
404
|
+
scope?: string;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function postToken(endpoint: string, body: URLSearchParams): Promise<TokenResponse> {
|
|
408
|
+
const res = await rawFetch(endpoint, {
|
|
409
|
+
method: "POST",
|
|
410
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
411
|
+
body: body.toString(),
|
|
412
|
+
});
|
|
413
|
+
const text = await res.text();
|
|
414
|
+
if (!res.ok) {
|
|
415
|
+
// The error body is a JSON object with `error` / `error_description`
|
|
416
|
+
// (RFC 6749 §5.2). Surface both; the code is what a test asserts on.
|
|
417
|
+
let code = `HTTP ${res.status}`;
|
|
418
|
+
let description: string | undefined = text.slice(0, 300);
|
|
419
|
+
try {
|
|
420
|
+
const parsed = JSON.parse(text) as { error?: string; error_description?: string };
|
|
421
|
+
if (parsed.error) code = parsed.error;
|
|
422
|
+
description = parsed.error_description ?? description;
|
|
423
|
+
} catch {
|
|
424
|
+
// Not JSON. The raw body is the best description available.
|
|
425
|
+
}
|
|
426
|
+
throw new McpAuthDeniedError(code, description);
|
|
427
|
+
}
|
|
428
|
+
const token = JSON.parse(text) as TokenResponse;
|
|
429
|
+
if (!token.access_token) throw new Error(`token endpoint returned no access_token: ${text}`);
|
|
430
|
+
return token;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
interface RegistrationResponse {
|
|
434
|
+
client_id: string;
|
|
435
|
+
client_secret?: string;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function registerClient(
|
|
439
|
+
endpoint: string,
|
|
440
|
+
init: { redirectUri: string; clientName: string; scopes: string[] },
|
|
441
|
+
): Promise<RegistrationResponse> {
|
|
442
|
+
const body: Record<string, unknown> = {
|
|
443
|
+
client_name: init.clientName,
|
|
444
|
+
redirect_uris: [init.redirectUri],
|
|
445
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
446
|
+
response_types: ["code"],
|
|
447
|
+
// A loopback client cannot keep a secret (RFC 8252 §8.4).
|
|
448
|
+
token_endpoint_auth_method: "none",
|
|
449
|
+
};
|
|
450
|
+
if (init.scopes.length > 0) body["scope"] = init.scopes.join(" ");
|
|
451
|
+
|
|
452
|
+
const res = await rawFetch(endpoint, {
|
|
453
|
+
method: "POST",
|
|
454
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
455
|
+
body: JSON.stringify(body),
|
|
456
|
+
});
|
|
457
|
+
const text = await res.text();
|
|
458
|
+
if (!res.ok) {
|
|
459
|
+
throw new Error(`dynamic client registration failed: HTTP ${res.status} — ${text.slice(0, 300)}`);
|
|
460
|
+
}
|
|
461
|
+
const parsed = JSON.parse(text) as RegistrationResponse;
|
|
462
|
+
if (!parsed.client_id) throw new Error(`registration returned no client_id: ${text}`);
|
|
463
|
+
return parsed;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function fetchProtectedResourceMetadata(
|
|
467
|
+
serverUrl: string,
|
|
468
|
+
resourceMetadataUrl?: string,
|
|
469
|
+
): Promise<ProtectedResourceMetadata | undefined> {
|
|
470
|
+
const candidates = resourceMetadataUrl
|
|
471
|
+
? [resourceMetadataUrl]
|
|
472
|
+
: wellKnownUrls(serverUrl, "oauth-protected-resource");
|
|
473
|
+
for (const url of candidates) {
|
|
474
|
+
const found = await fetchJson<ProtectedResourceMetadata>(url);
|
|
475
|
+
if (found) return found;
|
|
476
|
+
}
|
|
477
|
+
// Legal: a server may protect itself without publishing metadata. The
|
|
478
|
+
// caller then falls back to the server's own origin as the issuer.
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function fetchAuthServerMetadata(issuer: string): Promise<AuthServerMetadata> {
|
|
483
|
+
for (const url of [
|
|
484
|
+
...wellKnownUrls(issuer, "oauth-authorization-server"),
|
|
485
|
+
...wellKnownUrls(issuer, "openid-configuration"),
|
|
486
|
+
]) {
|
|
487
|
+
const found = await fetchJson<AuthServerMetadata>(url);
|
|
488
|
+
if (found?.authorization_endpoint && found?.token_endpoint) {
|
|
489
|
+
return { ...found, issuer: found.issuer ?? issuer };
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// MCP's fallback for a server that publishes nothing.
|
|
493
|
+
const base = issuer.replace(/\/$/, "");
|
|
494
|
+
return {
|
|
495
|
+
issuer,
|
|
496
|
+
authorization_endpoint: `${base}/authorize`,
|
|
497
|
+
token_endpoint: `${base}/token`,
|
|
498
|
+
registration_endpoint: `${base}/register`,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Candidate well-known URLs for an issuer, in the order to try them.
|
|
504
|
+
*
|
|
505
|
+
* RFC 8414 inserts the well-known segment BEFORE the issuer's path:
|
|
506
|
+
* `https://host/tenant` is discovered at
|
|
507
|
+
* `https://host/.well-known/oauth-authorization-server/tenant`. OpenID
|
|
508
|
+
* Connect appends instead. A path-carrying issuer therefore has two valid
|
|
509
|
+
* spellings and servers differ on which they serve, so we try the RFC 8414
|
|
510
|
+
* order first and fall back.
|
|
511
|
+
*/
|
|
512
|
+
export function wellKnownUrls(issuer: string, suffix: string): string[] {
|
|
513
|
+
const url = new URL(issuer);
|
|
514
|
+
const path = url.pathname.replace(/\/$/, "");
|
|
515
|
+
const root = `${url.origin}/.well-known/${suffix}`;
|
|
516
|
+
if (path === "" || path === "/") return [root];
|
|
517
|
+
return [`${url.origin}/.well-known/${suffix}${path}`, `${url.origin}${path}/.well-known/${suffix}`, root];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The canonical resource identifier (RFC 8707): the server URL with no
|
|
521
|
+
* fragment, and a lowercase host. */
|
|
522
|
+
export function canonicalResource(serverUrl: string): string {
|
|
523
|
+
const url = new URL(serverUrl);
|
|
524
|
+
url.hash = "";
|
|
525
|
+
url.host = url.host.toLowerCase();
|
|
526
|
+
return url.toString();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function fetchJson<T>(url: string): Promise<T | undefined> {
|
|
530
|
+
try {
|
|
531
|
+
const res = await rawFetch(url, { headers: { accept: "application/json" } });
|
|
532
|
+
if (!res.ok) return undefined;
|
|
533
|
+
return (await res.json()) as T;
|
|
534
|
+
} catch {
|
|
535
|
+
// An unreachable or non-JSON endpoint is a miss, not a failure: the
|
|
536
|
+
// caller has other candidates and a documented fallback.
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function splitScope(scope?: string): string[] | undefined {
|
|
542
|
+
if (!scope) return undefined;
|
|
543
|
+
const parts = scope.split(/\s+/).filter((s) => s !== "");
|
|
544
|
+
return parts.length > 0 ? parts : undefined;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function base64url(bytes: Uint8Array | string): string {
|
|
548
|
+
const buf = typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes;
|
|
549
|
+
let binary = "";
|
|
550
|
+
for (const b of buf) binary += String.fromCharCode(b);
|
|
551
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** The loopback callback server. One per flow, stopped when the flow
|
|
555
|
+
* settles. */
|
|
556
|
+
interface LoopbackListener {
|
|
557
|
+
redirectUri: string;
|
|
558
|
+
wait(timeoutMs: number): Promise<URLSearchParams>;
|
|
559
|
+
stop(): void;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const CALLBACK_PATH = "/callback";
|
|
563
|
+
|
|
564
|
+
/** Landing page. The browser is a real browser driven by a test, so this
|
|
565
|
+
* is what a screenshot of the last step will show. */
|
|
566
|
+
const CALLBACK_HTML = `<!doctype html>
|
|
567
|
+
<meta charset="utf-8">
|
|
568
|
+
<title>Authorized</title>
|
|
569
|
+
<body style="font: 16px system-ui; padding: 3rem; color: #222">
|
|
570
|
+
<h1 style="font-size: 1.25rem">Authorized</h1>
|
|
571
|
+
<p>You can close this window.</p>
|
|
572
|
+
`;
|
|
573
|
+
|
|
574
|
+
async function startLoopbackListener(host: LoopbackHost): Promise<LoopbackListener> {
|
|
575
|
+
let resolve: ((params: URLSearchParams) => void) | undefined;
|
|
576
|
+
const received = new Promise<URLSearchParams>((r) => {
|
|
577
|
+
resolve = r;
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// Port 0 asks the kernel for a free port. It is registered with the
|
|
581
|
+
// authorization server a moment later, which is exactly why dynamic
|
|
582
|
+
// client registration exists (RFC 8252 §7.3 also forbids a server from
|
|
583
|
+
// pinning the port of a loopback redirect).
|
|
584
|
+
const bun = (globalThis as { Bun?: { serve(opts: unknown): LoopbackServer } }).Bun;
|
|
585
|
+
if (!bun) throw new Error("the OAuth loopback listener needs Bun's HTTP server");
|
|
586
|
+
const server = bun.serve({
|
|
587
|
+
hostname: host,
|
|
588
|
+
port: 0,
|
|
589
|
+
fetch(req: Request) {
|
|
590
|
+
const url = new URL(req.url);
|
|
591
|
+
if (url.pathname !== CALLBACK_PATH) return new Response("not found", { status: 404 });
|
|
592
|
+
resolve?.(url.searchParams);
|
|
593
|
+
return new Response(CALLBACK_HTML, {
|
|
594
|
+
status: 200,
|
|
595
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
596
|
+
});
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
redirectUri: `http://${host}:${server.port}${CALLBACK_PATH}`,
|
|
602
|
+
async wait(timeoutMs: number): Promise<URLSearchParams> {
|
|
603
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
604
|
+
const expired = new Promise<never>((_, reject) => {
|
|
605
|
+
timer = setTimeout(
|
|
606
|
+
() =>
|
|
607
|
+
reject(
|
|
608
|
+
new Error(
|
|
609
|
+
`no authorization redirect arrived within ${timeoutMs} ms. ` +
|
|
610
|
+
"Did the test navigate a browser to auth.url and complete the sign-in?",
|
|
611
|
+
),
|
|
612
|
+
),
|
|
613
|
+
timeoutMs,
|
|
614
|
+
);
|
|
615
|
+
});
|
|
616
|
+
try {
|
|
617
|
+
return await Promise.race([received, expired]);
|
|
618
|
+
} finally {
|
|
619
|
+
if (timer) clearTimeout(timer);
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
stop(): void {
|
|
623
|
+
server.stop(true);
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
}
|