@frockbot/plugin-mcp 0.0.0 → 0.1.0
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/frockbot.json +183 -0
- package/package.json +41 -6
- package/src/agent.test.ts +409 -0
- package/src/agent.ts +516 -0
- package/src/backend.test.ts +333 -0
- package/src/backend.ts +490 -0
- package/src/connect-card.test.ts +226 -0
- package/src/index.ts +7 -0
- package/src/lifecycle-tools.test.ts +182 -0
- package/src/lifecycle-tools.ts +401 -0
- package/src/lifecycle.test.ts +504 -0
- package/src/manifest.ts +3 -0
- package/src/mcp-client.test.ts +389 -0
- package/src/mcp-client.ts +645 -0
- package/src/oauth-records.ts +330 -0
- package/src/oauth-user.test.ts +776 -0
- package/src/oauth.test.ts +433 -0
- package/src/oauth.ts +747 -0
- package/src/records.test.ts +331 -0
- package/src/records.ts +754 -0
- package/src/ssrf.test.ts +38 -0
- package/src/ssrf.ts +44 -0
- package/src/user.test.ts +390 -0
- package/src/user.ts +2068 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mcp-oauth` grant driver: everything the MCP authorization specification
|
|
3
|
+
* asks of a client, written against the published spec rather than an SDK.
|
|
4
|
+
*
|
|
5
|
+
* `@modelcontextprotocol/sdk` is not a dependency of this repository and its
|
|
6
|
+
* OAuth helper carries a Node HTTP client and a filesystem token store, both of
|
|
7
|
+
* which this Package must never reach for. What FrockBot needs is narrow —
|
|
8
|
+
* discover, register, authorize, exchange, refresh, revoke — and every byte of
|
|
9
|
+
* it is bounded, https-only, and passed through the same SSRF classifier the
|
|
10
|
+
* MCP endpoint itself is held to.
|
|
11
|
+
*
|
|
12
|
+
* The specification this implements (revisions 2025-06-18 and 2025-11-25):
|
|
13
|
+
*
|
|
14
|
+
* - **RFC 9728** protected-resource metadata, discovered path-aware from the
|
|
15
|
+
* MCP server URL or named by the `resource_metadata` parameter of the
|
|
16
|
+
* `WWW-Authenticate` header on a 401.
|
|
17
|
+
* - **RFC 8414** authorization-server metadata, tried in the order the spec
|
|
18
|
+
* sets out, with `issuer` checked against the URL it was fetched from.
|
|
19
|
+
* - **RFC 7591** dynamic client registration, as a *public* client
|
|
20
|
+
* (`token_endpoint_auth_method: "none"`). A server that insists on a client
|
|
21
|
+
* secret is refused durably: a secret in a Connection setting is a secret in
|
|
22
|
+
* a projection.
|
|
23
|
+
* - **PKCE** with `S256`, mandatory. The verifier is generated in the User
|
|
24
|
+
* Durable Object, stored in the pending record, and never leaves it.
|
|
25
|
+
* - **RFC 8707** `resource` indicators on both the authorization request and
|
|
26
|
+
* every token request, so the token the server issues is bound to this MCP
|
|
27
|
+
* server and useless anywhere else.
|
|
28
|
+
* - **RFC 7009** token revocation on disconnect, when the server advertises an
|
|
29
|
+
* endpoint.
|
|
30
|
+
*
|
|
31
|
+
* Nothing here touches durable state or secrets storage: this module composes
|
|
32
|
+
* and decodes requests, and the User Durable Object that calls it owns every
|
|
33
|
+
* record and every sealed credential.
|
|
34
|
+
*/
|
|
35
|
+
import {
|
|
36
|
+
McpAuthorizationRequiredError,
|
|
37
|
+
mcpResourceMetadataChallengeV1,
|
|
38
|
+
type McpFetch,
|
|
39
|
+
} from "./mcp-client.js";
|
|
40
|
+
import { decodeOutboundMcpUrlV1 } from "./ssrf.js";
|
|
41
|
+
|
|
42
|
+
/** The driver id the manifest names for the `mcp-remote-oauth` grant. */
|
|
43
|
+
export const MCP_OAUTH_DRIVER_ID = "mcp-oauth";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A metadata document is small. Bounding it is not a nicety: these are the
|
|
47
|
+
* first bytes a User's Durable Object reads from a URL a User named, and an
|
|
48
|
+
* unbounded read there is an unbounded read inside the authority.
|
|
49
|
+
*/
|
|
50
|
+
export const MAX_MCP_METADATA_BYTES_V1 = 64 * 1024;
|
|
51
|
+
|
|
52
|
+
/** How long an authorization may sit pending before its state expires. */
|
|
53
|
+
export const MCP_AUTHORIZATION_TTL_MS_V1 = 10 * 60_000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The per-User ceiling on authorization starts inside one window. A start
|
|
57
|
+
* costs outbound discovery requests and a durable pending record, so it is a
|
|
58
|
+
* quota like every other; exceeding it is a visible refusal.
|
|
59
|
+
*/
|
|
60
|
+
export const MAX_MCP_AUTHORIZATION_STARTS_V1 = 24;
|
|
61
|
+
export const MCP_AUTHORIZATION_START_WINDOW_MS_V1 = 60 * 60_000;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How long before expiry an access token is refreshed on the way out. A lease
|
|
65
|
+
* lives five minutes; a token that expires inside the next minute would expire
|
|
66
|
+
* mid-mount, so it is replaced before it is handed over.
|
|
67
|
+
*/
|
|
68
|
+
export const MCP_ACCESS_REFRESH_SKEW_MS_V1 = 60_000;
|
|
69
|
+
|
|
70
|
+
/** What this client calls itself when it registers. */
|
|
71
|
+
const CLIENT_NAME = "FrockBot";
|
|
72
|
+
|
|
73
|
+
export interface McpProtectedResourceMetadataV1 {
|
|
74
|
+
resource: string;
|
|
75
|
+
authorizationServers: string[];
|
|
76
|
+
scopesSupported?: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface McpAuthorizationServerMetadataV1 {
|
|
80
|
+
issuer: string;
|
|
81
|
+
authorizationEndpoint: string;
|
|
82
|
+
tokenEndpoint: string;
|
|
83
|
+
registrationEndpoint?: string;
|
|
84
|
+
revocationEndpoint?: string;
|
|
85
|
+
codeChallengeMethodsSupported: string[];
|
|
86
|
+
tokenEndpointAuthMethodsSupported?: string[];
|
|
87
|
+
scopesSupported?: string[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface McpOAuthTokenSetV1 {
|
|
91
|
+
accessToken: string;
|
|
92
|
+
tokenType: string;
|
|
93
|
+
/** Absolute, as an epoch millisecond. Absent when the server declared none. */
|
|
94
|
+
expiresAt?: number;
|
|
95
|
+
refreshToken?: string;
|
|
96
|
+
scope?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* An authorization the client cannot complete. Separate from
|
|
101
|
+
* `McpProtocolError` because none of these are the MCP protocol: they are the
|
|
102
|
+
* authorization server's answers, and the code is what the durable record
|
|
103
|
+
* branches on.
|
|
104
|
+
*/
|
|
105
|
+
export class McpAuthorizationError extends Error {
|
|
106
|
+
readonly code:
|
|
107
|
+
| "authorization-discovery"
|
|
108
|
+
| "unsupported-client-authentication"
|
|
109
|
+
| "authorization-failed";
|
|
110
|
+
|
|
111
|
+
constructor(
|
|
112
|
+
message: string,
|
|
113
|
+
code: McpAuthorizationError["code"] = "authorization-failed",
|
|
114
|
+
) {
|
|
115
|
+
super(message);
|
|
116
|
+
this.code = code;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function mcpResourceMetadataUrlsV1(serverUrl: URL): string[] {
|
|
121
|
+
const path = serverUrl.pathname.replace(/\/+$/, "");
|
|
122
|
+
const urls =
|
|
123
|
+
path && path !== "/"
|
|
124
|
+
? [`${serverUrl.origin}/.well-known/oauth-protected-resource${path}`]
|
|
125
|
+
: [];
|
|
126
|
+
urls.push(`${serverUrl.origin}/.well-known/oauth-protected-resource`);
|
|
127
|
+
return urls;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Where an issuer's authorization-server metadata lives, in the order the MCP
|
|
132
|
+
* specification requires: RFC 8414 path insertion first, then the OpenID
|
|
133
|
+
* Connect variants, then the path-appended OpenID document. An issuer with no
|
|
134
|
+
* path collapses the four to two, which is the common case.
|
|
135
|
+
*/
|
|
136
|
+
export function mcpAuthorizationServerMetadataUrlsV1(issuer: URL): string[] {
|
|
137
|
+
const path = issuer.pathname.replace(/\/+$/, "");
|
|
138
|
+
if (!path || path === "/") {
|
|
139
|
+
return [
|
|
140
|
+
`${issuer.origin}/.well-known/oauth-authorization-server`,
|
|
141
|
+
`${issuer.origin}/.well-known/openid-configuration`,
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
return [
|
|
145
|
+
`${issuer.origin}/.well-known/oauth-authorization-server${path}`,
|
|
146
|
+
`${issuer.origin}/.well-known/openid-configuration${path}`,
|
|
147
|
+
`${issuer.origin}${path}/.well-known/openid-configuration`,
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The canonical resource indicator (RFC 8707 §2, as the MCP specification
|
|
153
|
+
* narrows it): the server's URI with its scheme and host lowercased, its path
|
|
154
|
+
* kept, and no fragment. The query is dropped — a resource identity that
|
|
155
|
+
* changes with a query string is not an identity.
|
|
156
|
+
*/
|
|
157
|
+
export function mcpCanonicalResourceV1(serverUrl: URL): string {
|
|
158
|
+
const path = serverUrl.pathname.replace(/\/+$/, "");
|
|
159
|
+
return `${serverUrl.protocol.toLowerCase()}//${serverUrl.host.toLowerCase()}${path}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function base64Url(bytes: Uint8Array): string {
|
|
163
|
+
let binary = "";
|
|
164
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
165
|
+
return btoa(binary)
|
|
166
|
+
.replaceAll("+", "-")
|
|
167
|
+
.replaceAll("/", "_")
|
|
168
|
+
.replace(/=+$/, "");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface McpPkcePairV1 {
|
|
172
|
+
/** 43–128 characters of the unreserved set, per RFC 7636 §4.1. */
|
|
173
|
+
codeVerifier: string;
|
|
174
|
+
codeChallenge: string;
|
|
175
|
+
codeChallengeMethod: "S256";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* One PKCE pair. `S256` only: RFC 7636 permits `plain`, and the MCP
|
|
180
|
+
* specification does not — a `plain` challenge is the same secret twice.
|
|
181
|
+
*/
|
|
182
|
+
export async function createPkcePairV1(
|
|
183
|
+
randomBytes: (length: number) => Uint8Array = (length) =>
|
|
184
|
+
crypto.getRandomValues(new Uint8Array(length)),
|
|
185
|
+
): Promise<McpPkcePairV1> {
|
|
186
|
+
const codeVerifier = base64Url(randomBytes(32));
|
|
187
|
+
const digest = await crypto.subtle.digest(
|
|
188
|
+
"SHA-256",
|
|
189
|
+
new TextEncoder().encode(codeVerifier),
|
|
190
|
+
);
|
|
191
|
+
return {
|
|
192
|
+
codeVerifier,
|
|
193
|
+
codeChallenge: base64Url(new Uint8Array(digest)),
|
|
194
|
+
codeChallengeMethod: "S256",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function mcpCodeChallengeV1(
|
|
199
|
+
codeVerifier: string,
|
|
200
|
+
): Promise<string> {
|
|
201
|
+
const digest = await crypto.subtle.digest(
|
|
202
|
+
"SHA-256",
|
|
203
|
+
new TextEncoder().encode(codeVerifier),
|
|
204
|
+
);
|
|
205
|
+
return base64Url(new Uint8Array(digest));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function metadataRecord(
|
|
209
|
+
value: unknown,
|
|
210
|
+
label: string,
|
|
211
|
+
): Record<string, unknown> {
|
|
212
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
213
|
+
throw new McpAuthorizationError(
|
|
214
|
+
`${label} is invalid`,
|
|
215
|
+
"authorization-discovery",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return value as Record<string, unknown>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function stringList(value: unknown, maximum = 64): string[] | undefined {
|
|
222
|
+
if (!Array.isArray(value)) return undefined;
|
|
223
|
+
const items = value.filter(
|
|
224
|
+
(item): item is string =>
|
|
225
|
+
typeof item === "string" && item.length > 0 && item.length <= 512,
|
|
226
|
+
);
|
|
227
|
+
return items.slice(0, maximum);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* An https URL from a metadata document, put through the same outbound
|
|
232
|
+
* classifier every MCP request is. A metadata document is attacker-influenced
|
|
233
|
+
* input the moment a User names a server, so a `http://` or private-address
|
|
234
|
+
* endpoint is refused here rather than fetched.
|
|
235
|
+
*/
|
|
236
|
+
function metadataUrl(value: unknown, label: string): URL {
|
|
237
|
+
try {
|
|
238
|
+
return decodeOutboundMcpUrlV1(value);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
throw new McpAuthorizationError(
|
|
241
|
+
`${label} is invalid: ${error instanceof Error ? error.message : "unknown"}`,
|
|
242
|
+
"authorization-discovery",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function decodeMcpProtectedResourceMetadataV1(
|
|
248
|
+
input: unknown,
|
|
249
|
+
): McpProtectedResourceMetadataV1 {
|
|
250
|
+
const value = metadataRecord(input, "MCP protected resource metadata");
|
|
251
|
+
const resource = value.resource;
|
|
252
|
+
if (typeof resource !== "string" || resource.length === 0) {
|
|
253
|
+
throw new McpAuthorizationError(
|
|
254
|
+
"MCP protected resource metadata names no resource",
|
|
255
|
+
"authorization-discovery",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
const servers = stringList(value.authorization_servers, 8) ?? [];
|
|
259
|
+
if (servers.length === 0) {
|
|
260
|
+
throw new McpAuthorizationError(
|
|
261
|
+
"MCP protected resource metadata names no authorization server",
|
|
262
|
+
"authorization-discovery",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const scopes = stringList(value.scopes_supported);
|
|
266
|
+
return {
|
|
267
|
+
resource,
|
|
268
|
+
authorizationServers: servers,
|
|
269
|
+
...(scopes && scopes.length > 0 ? { scopesSupported: scopes } : {}),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The authorization-server metadata, checked against the URL it came from.
|
|
275
|
+
*
|
|
276
|
+
* Two rules carry the weight. `issuer` must be the issuer this document was
|
|
277
|
+
* fetched for — RFC 8414 §3.3 makes a mismatch a mix-up attack, not a typo.
|
|
278
|
+
* And every endpoint must share the issuer's origin: a document that could
|
|
279
|
+
* point the token request at a third host is a document that could be served
|
|
280
|
+
* by one host and redeemed at another.
|
|
281
|
+
*/
|
|
282
|
+
export function decodeMcpAuthorizationServerMetadataV1(
|
|
283
|
+
input: unknown,
|
|
284
|
+
issuerIdentifier: string,
|
|
285
|
+
): McpAuthorizationServerMetadataV1 {
|
|
286
|
+
const value = metadataRecord(input, "MCP authorization server metadata");
|
|
287
|
+
const declared = value.issuer;
|
|
288
|
+
if (typeof declared !== "string") {
|
|
289
|
+
throw new McpAuthorizationError(
|
|
290
|
+
"MCP authorization server metadata declares no issuer",
|
|
291
|
+
"authorization-discovery",
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const declaredIssuer = metadataUrl(declared, "authorization server issuer");
|
|
295
|
+
// RFC 8414 §3.3 compares the *strings*, not two normalized URLs: this is the
|
|
296
|
+
// mix-up-attack defence, and a comparison that normalizes is a comparison an
|
|
297
|
+
// attacker chooses the normalization for.
|
|
298
|
+
if (declared !== issuerIdentifier) {
|
|
299
|
+
throw new McpAuthorizationError(
|
|
300
|
+
`MCP authorization server metadata declares issuer "${declared}", which is not the issuer it was fetched for`,
|
|
301
|
+
"authorization-discovery",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const authorizationEndpoint = metadataUrl(
|
|
305
|
+
value.authorization_endpoint,
|
|
306
|
+
"authorization endpoint",
|
|
307
|
+
);
|
|
308
|
+
const tokenEndpoint = metadataUrl(value.token_endpoint, "token endpoint");
|
|
309
|
+
const registrationEndpoint =
|
|
310
|
+
value.registration_endpoint === undefined
|
|
311
|
+
? undefined
|
|
312
|
+
: metadataUrl(value.registration_endpoint, "registration endpoint");
|
|
313
|
+
const revocationEndpoint =
|
|
314
|
+
value.revocation_endpoint === undefined
|
|
315
|
+
? undefined
|
|
316
|
+
: metadataUrl(value.revocation_endpoint, "revocation endpoint");
|
|
317
|
+
for (const [endpoint, label] of [
|
|
318
|
+
[authorizationEndpoint, "authorization endpoint"],
|
|
319
|
+
[tokenEndpoint, "token endpoint"],
|
|
320
|
+
...(registrationEndpoint
|
|
321
|
+
? ([[registrationEndpoint, "registration endpoint"]] as const)
|
|
322
|
+
: []),
|
|
323
|
+
...(revocationEndpoint
|
|
324
|
+
? ([[revocationEndpoint, "revocation endpoint"]] as const)
|
|
325
|
+
: []),
|
|
326
|
+
] as ReadonlyArray<readonly [URL, string]>) {
|
|
327
|
+
if (endpoint.origin !== declaredIssuer.origin) {
|
|
328
|
+
throw new McpAuthorizationError(
|
|
329
|
+
`MCP authorization server ${label} is on a different origin from its issuer`,
|
|
330
|
+
"authorization-discovery",
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// The 2025-11-25 revision makes this a pre-flight refusal rather than a
|
|
335
|
+
// best-effort: an absent `code_challenge_methods_supported` means the server
|
|
336
|
+
// does not support PKCE at all (RFC 8414 §2), and an MCP client must not
|
|
337
|
+
// proceed without it.
|
|
338
|
+
const challengeMethods = stringList(value.code_challenge_methods_supported);
|
|
339
|
+
if (!challengeMethods || !challengeMethods.includes("S256")) {
|
|
340
|
+
throw new McpAuthorizationError(
|
|
341
|
+
"MCP authorization server does not advertise the S256 PKCE challenge method, which this client requires",
|
|
342
|
+
"authorization-discovery",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
const authMethods = stringList(value.token_endpoint_auth_methods_supported);
|
|
346
|
+
const scopes = stringList(value.scopes_supported);
|
|
347
|
+
return {
|
|
348
|
+
issuer: declared,
|
|
349
|
+
authorizationEndpoint: authorizationEndpoint.toString(),
|
|
350
|
+
tokenEndpoint: tokenEndpoint.toString(),
|
|
351
|
+
...(registrationEndpoint
|
|
352
|
+
? { registrationEndpoint: registrationEndpoint.toString() }
|
|
353
|
+
: {}),
|
|
354
|
+
...(revocationEndpoint
|
|
355
|
+
? { revocationEndpoint: revocationEndpoint.toString() }
|
|
356
|
+
: {}),
|
|
357
|
+
codeChallengeMethodsSupported: challengeMethods,
|
|
358
|
+
...(authMethods ? { tokenEndpointAuthMethodsSupported: authMethods } : {}),
|
|
359
|
+
...(scopes && scopes.length > 0 ? { scopesSupported: scopes } : {}),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The authorize URL, built here and only here.
|
|
365
|
+
*
|
|
366
|
+
* The model never composes an authorization link and the client never receives
|
|
367
|
+
* the parts to build one: this returns a complete URL, minted by the host,
|
|
368
|
+
* carrying the signed state and the PKCE challenge that bind it to one pending
|
|
369
|
+
* record.
|
|
370
|
+
*/
|
|
371
|
+
export function mcpAuthorizeUrlV1(input: {
|
|
372
|
+
authorizationEndpoint: string;
|
|
373
|
+
clientId: string;
|
|
374
|
+
redirectUri: string;
|
|
375
|
+
state: string;
|
|
376
|
+
codeChallenge: string;
|
|
377
|
+
resource: string;
|
|
378
|
+
scope?: string;
|
|
379
|
+
}): string {
|
|
380
|
+
const url = new URL(input.authorizationEndpoint);
|
|
381
|
+
url.searchParams.set("response_type", "code");
|
|
382
|
+
url.searchParams.set("client_id", input.clientId);
|
|
383
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
384
|
+
url.searchParams.set("state", input.state);
|
|
385
|
+
url.searchParams.set("code_challenge", input.codeChallenge);
|
|
386
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
387
|
+
// RFC 8707: the token this authorization produces is for this MCP server and
|
|
388
|
+
// nothing else. Sent here as well as at the token endpoint, because an
|
|
389
|
+
// authorization server that honours it only at one of the two would issue an
|
|
390
|
+
// over-broad token at the other.
|
|
391
|
+
url.searchParams.set("resource", input.resource);
|
|
392
|
+
if (input.scope) url.searchParams.set("scope", input.scope);
|
|
393
|
+
return url.toString();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
interface JsonResponse {
|
|
397
|
+
status: number;
|
|
398
|
+
body: Record<string, unknown>;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export interface McpOAuthClientConfig {
|
|
402
|
+
fetch: McpFetch;
|
|
403
|
+
now?: () => number;
|
|
404
|
+
maxMetadataBytes?: number;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Read a JSON body under a byte ceiling, refusing anything past it rather than
|
|
409
|
+
* truncating. Deliberately the same shape as `McpClient`'s reader: an
|
|
410
|
+
* authorization server is exactly as untrusted as an MCP server.
|
|
411
|
+
*/
|
|
412
|
+
async function boundedJson(
|
|
413
|
+
response: Response,
|
|
414
|
+
maximum: number,
|
|
415
|
+
): Promise<Record<string, unknown>> {
|
|
416
|
+
const declared = Number(response.headers.get("content-length"));
|
|
417
|
+
if (Number.isFinite(declared) && declared > maximum) {
|
|
418
|
+
throw new McpAuthorizationError(
|
|
419
|
+
"MCP authorization response is too large",
|
|
420
|
+
"authorization-discovery",
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
const reader = response.body?.getReader();
|
|
424
|
+
if (!reader) return {};
|
|
425
|
+
const decoder = new TextDecoder();
|
|
426
|
+
let text = "";
|
|
427
|
+
let length = 0;
|
|
428
|
+
try {
|
|
429
|
+
for (;;) {
|
|
430
|
+
const chunk = await reader.read();
|
|
431
|
+
if (chunk.done) break;
|
|
432
|
+
length += chunk.value.byteLength;
|
|
433
|
+
if (length > maximum) {
|
|
434
|
+
throw new McpAuthorizationError(
|
|
435
|
+
"MCP authorization response is too large",
|
|
436
|
+
"authorization-discovery",
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
440
|
+
}
|
|
441
|
+
} finally {
|
|
442
|
+
reader.releaseLock();
|
|
443
|
+
}
|
|
444
|
+
text += decoder.decode();
|
|
445
|
+
if (!text.trim()) return {};
|
|
446
|
+
let parsed: unknown;
|
|
447
|
+
try {
|
|
448
|
+
parsed = JSON.parse(text);
|
|
449
|
+
} catch {
|
|
450
|
+
throw new McpAuthorizationError(
|
|
451
|
+
"MCP authorization response is not JSON",
|
|
452
|
+
"authorization-discovery",
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
return metadataRecord(parsed, "MCP authorization response");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function errorDetail(body: Record<string, unknown>, status: number): string {
|
|
459
|
+
const code = typeof body.error === "string" ? body.error : undefined;
|
|
460
|
+
const description =
|
|
461
|
+
typeof body.error_description === "string"
|
|
462
|
+
? body.error_description
|
|
463
|
+
: undefined;
|
|
464
|
+
const detail = [code, description].filter(Boolean).join(": ");
|
|
465
|
+
return detail ? `${status} ${detail}`.slice(0, 500) : String(status);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Everything that leaves the deployment for an authorization server. It holds
|
|
470
|
+
* no state and no secret: the caller supplies each value and keeps every one
|
|
471
|
+
* of them.
|
|
472
|
+
*/
|
|
473
|
+
export class McpOAuthClient {
|
|
474
|
+
private readonly maxMetadataBytes: number;
|
|
475
|
+
private readonly now: () => number;
|
|
476
|
+
|
|
477
|
+
constructor(private readonly config: McpOAuthClientConfig) {
|
|
478
|
+
this.maxMetadataBytes =
|
|
479
|
+
config.maxMetadataBytes ?? MAX_MCP_METADATA_BYTES_V1;
|
|
480
|
+
this.now = config.now ?? (() => Date.now());
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private async getJson(url: URL): Promise<JsonResponse> {
|
|
484
|
+
const response = await this.config.fetch(url.toString(), {
|
|
485
|
+
method: "GET",
|
|
486
|
+
headers: { accept: "application/json" },
|
|
487
|
+
});
|
|
488
|
+
if (!response.ok) {
|
|
489
|
+
await response.body?.cancel();
|
|
490
|
+
return { status: response.status, body: {} };
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
status: response.status,
|
|
494
|
+
body: await boundedJson(response, this.maxMetadataBytes),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private async postForm(
|
|
499
|
+
url: URL,
|
|
500
|
+
form: Record<string, string>,
|
|
501
|
+
): Promise<JsonResponse> {
|
|
502
|
+
const body = new URLSearchParams(form);
|
|
503
|
+
const response = await this.config.fetch(url.toString(), {
|
|
504
|
+
method: "POST",
|
|
505
|
+
headers: {
|
|
506
|
+
accept: "application/json",
|
|
507
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
508
|
+
},
|
|
509
|
+
body: body.toString(),
|
|
510
|
+
});
|
|
511
|
+
return {
|
|
512
|
+
status: response.status,
|
|
513
|
+
body: await boundedJson(response, this.maxMetadataBytes),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The protected-resource document for one MCP server: the `resource_metadata`
|
|
519
|
+
* the server named on its 401 when it named one, else the path-aware
|
|
520
|
+
* well-known locations in order.
|
|
521
|
+
*/
|
|
522
|
+
async discoverProtectedResource(input: {
|
|
523
|
+
serverUrl: URL;
|
|
524
|
+
resourceMetadataUrl?: string;
|
|
525
|
+
}): Promise<McpProtectedResourceMetadataV1> {
|
|
526
|
+
const candidates = input.resourceMetadataUrl
|
|
527
|
+
? [input.resourceMetadataUrl]
|
|
528
|
+
: mcpResourceMetadataUrlsV1(input.serverUrl);
|
|
529
|
+
let lastStatus = 0;
|
|
530
|
+
for (const candidate of candidates) {
|
|
531
|
+
const url = metadataUrl(candidate, "protected resource metadata URL");
|
|
532
|
+
const found = await this.getJson(url);
|
|
533
|
+
if (found.status === 200) {
|
|
534
|
+
const metadata = decodeMcpProtectedResourceMetadataV1(found.body);
|
|
535
|
+
// RFC 9728 §3.3, and the whole of the anti-impersonation rule: the
|
|
536
|
+
// `resource` the document declares must be the resource it was fetched
|
|
537
|
+
// for. When the URL came from the server's own `WWW-Authenticate`
|
|
538
|
+
// header, that comparison is against the MCP endpoint the client
|
|
539
|
+
// called — a header naming someone else's metadata document is exactly
|
|
540
|
+
// the attack the rule exists for.
|
|
541
|
+
metadataUrl(metadata.resource, "resource identifier");
|
|
542
|
+
if (
|
|
543
|
+
mcpCanonicalResourceV1(new URL(metadata.resource)) !==
|
|
544
|
+
mcpCanonicalResourceV1(input.serverUrl)
|
|
545
|
+
) {
|
|
546
|
+
throw new McpAuthorizationError(
|
|
547
|
+
`MCP protected resource metadata declares resource "${metadata.resource}", which is not the server it was fetched for`,
|
|
548
|
+
"authorization-discovery",
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
return metadata;
|
|
552
|
+
}
|
|
553
|
+
lastStatus = found.status;
|
|
554
|
+
}
|
|
555
|
+
throw new McpAuthorizationError(
|
|
556
|
+
`MCP server published no protected resource metadata (${lastStatus || "unreachable"})`,
|
|
557
|
+
"authorization-discovery",
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** The authorization-server document for one issuer, tried in spec order. */
|
|
562
|
+
async discoverAuthorizationServer(
|
|
563
|
+
issuerUrl: string,
|
|
564
|
+
): Promise<McpAuthorizationServerMetadataV1> {
|
|
565
|
+
const issuer = metadataUrl(issuerUrl, "authorization server issuer");
|
|
566
|
+
let lastStatus = 0;
|
|
567
|
+
for (const candidate of mcpAuthorizationServerMetadataUrlsV1(issuer)) {
|
|
568
|
+
const found = await this.getJson(new URL(candidate));
|
|
569
|
+
if (found.status === 200) {
|
|
570
|
+
return decodeMcpAuthorizationServerMetadataV1(found.body, issuerUrl);
|
|
571
|
+
}
|
|
572
|
+
lastStatus = found.status;
|
|
573
|
+
}
|
|
574
|
+
throw new McpAuthorizationError(
|
|
575
|
+
`MCP authorization server published no metadata (${lastStatus || "unreachable"})`,
|
|
576
|
+
"authorization-discovery",
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* RFC 7591 dynamic registration, as a public client.
|
|
582
|
+
*
|
|
583
|
+
* A server that answers with a `client_secret` is refused: FrockBot would
|
|
584
|
+
* have to keep that secret somewhere, and the only somewhere a Connection
|
|
585
|
+
* offers is a projection every client can read. The refusal is durable and
|
|
586
|
+
* carries its own code, so a User reads "this server needs a client secret"
|
|
587
|
+
* rather than "authorization failed".
|
|
588
|
+
*/
|
|
589
|
+
async register(input: {
|
|
590
|
+
registrationEndpoint: string;
|
|
591
|
+
redirectUri: string;
|
|
592
|
+
scope?: string;
|
|
593
|
+
}): Promise<{ clientId: string }> {
|
|
594
|
+
const response = await this.config.fetch(input.registrationEndpoint, {
|
|
595
|
+
method: "POST",
|
|
596
|
+
headers: {
|
|
597
|
+
accept: "application/json",
|
|
598
|
+
"content-type": "application/json",
|
|
599
|
+
},
|
|
600
|
+
body: JSON.stringify({
|
|
601
|
+
client_name: CLIENT_NAME,
|
|
602
|
+
redirect_uris: [input.redirectUri],
|
|
603
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
604
|
+
response_types: ["code"],
|
|
605
|
+
token_endpoint_auth_method: "none",
|
|
606
|
+
...(input.scope ? { scope: input.scope } : {}),
|
|
607
|
+
}),
|
|
608
|
+
});
|
|
609
|
+
const body = await boundedJson(response, this.maxMetadataBytes);
|
|
610
|
+
if (response.status !== 200 && response.status !== 201) {
|
|
611
|
+
throw new McpAuthorizationError(
|
|
612
|
+
`MCP client registration failed: ${errorDetail(body, response.status)}`,
|
|
613
|
+
"authorization-discovery",
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
const clientId = body.client_id;
|
|
617
|
+
if (typeof clientId !== "string" || clientId.length === 0) {
|
|
618
|
+
throw new McpAuthorizationError(
|
|
619
|
+
"MCP client registration returned no client_id",
|
|
620
|
+
"authorization-discovery",
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
if (typeof body.client_secret === "string" && body.client_secret) {
|
|
624
|
+
throw new McpAuthorizationError(
|
|
625
|
+
"The authorization server issued a confidential client. FrockBot registers as a public client, because a client secret would have to live in a Connection setting every client can read.",
|
|
626
|
+
"unsupported-client-authentication",
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
return { clientId: clientId.slice(0, 512) };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** The authorization-code grant, with the stored verifier and the resource. */
|
|
633
|
+
async exchangeCode(input: {
|
|
634
|
+
tokenEndpoint: string;
|
|
635
|
+
clientId: string;
|
|
636
|
+
code: string;
|
|
637
|
+
codeVerifier: string;
|
|
638
|
+
redirectUri: string;
|
|
639
|
+
resource: string;
|
|
640
|
+
}): Promise<McpOAuthTokenSetV1> {
|
|
641
|
+
return this.token(input.tokenEndpoint, {
|
|
642
|
+
grant_type: "authorization_code",
|
|
643
|
+
code: input.code,
|
|
644
|
+
redirect_uri: input.redirectUri,
|
|
645
|
+
client_id: input.clientId,
|
|
646
|
+
code_verifier: input.codeVerifier,
|
|
647
|
+
resource: input.resource,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** The refresh grant. Same audience binding; a new refresh token replaces the old when one is returned. */
|
|
652
|
+
async refresh(input: {
|
|
653
|
+
tokenEndpoint: string;
|
|
654
|
+
clientId: string;
|
|
655
|
+
refreshToken: string;
|
|
656
|
+
resource: string;
|
|
657
|
+
scope?: string;
|
|
658
|
+
}): Promise<McpOAuthTokenSetV1> {
|
|
659
|
+
return this.token(input.tokenEndpoint, {
|
|
660
|
+
grant_type: "refresh_token",
|
|
661
|
+
refresh_token: input.refreshToken,
|
|
662
|
+
client_id: input.clientId,
|
|
663
|
+
resource: input.resource,
|
|
664
|
+
...(input.scope ? { scope: input.scope } : {}),
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private async token(
|
|
669
|
+
tokenEndpoint: string,
|
|
670
|
+
form: Record<string, string>,
|
|
671
|
+
): Promise<McpOAuthTokenSetV1> {
|
|
672
|
+
const url = metadataUrl(tokenEndpoint, "token endpoint");
|
|
673
|
+
const { status, body } = await this.postForm(url, form);
|
|
674
|
+
if (status !== 200) {
|
|
675
|
+
throw new McpAuthorizationError(
|
|
676
|
+
`MCP token request failed: ${errorDetail(body, status)}`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
const accessToken = body.access_token;
|
|
680
|
+
if (typeof accessToken !== "string" || accessToken.length === 0) {
|
|
681
|
+
throw new McpAuthorizationError("MCP token response carried no token");
|
|
682
|
+
}
|
|
683
|
+
if (accessToken.length > 8_192) {
|
|
684
|
+
throw new McpAuthorizationError("MCP access token is too large");
|
|
685
|
+
}
|
|
686
|
+
const expiresIn = body.expires_in;
|
|
687
|
+
const refreshToken = body.refresh_token;
|
|
688
|
+
const scope = body.scope;
|
|
689
|
+
const tokenType = body.token_type;
|
|
690
|
+
return {
|
|
691
|
+
accessToken,
|
|
692
|
+
tokenType:
|
|
693
|
+
typeof tokenType === "string" && tokenType ? tokenType : "Bearer",
|
|
694
|
+
...(typeof expiresIn === "number" && Number.isFinite(expiresIn)
|
|
695
|
+
? { expiresAt: this.now() + Math.max(0, Math.floor(expiresIn)) * 1_000 }
|
|
696
|
+
: {}),
|
|
697
|
+
...(typeof refreshToken === "string" &&
|
|
698
|
+
refreshToken.length > 0 &&
|
|
699
|
+
refreshToken.length <= 8_192
|
|
700
|
+
? { refreshToken }
|
|
701
|
+
: {}),
|
|
702
|
+
...(typeof scope === "string" && scope.length <= 1_024 ? { scope } : {}),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* RFC 7009 revocation. The specification makes an unknown token a *success*,
|
|
708
|
+
* so anything but a transport failure or a server error counts as revoked;
|
|
709
|
+
* only the absence of an endpoint leaves the Connection needing
|
|
710
|
+
* reconciliation.
|
|
711
|
+
*/
|
|
712
|
+
async revoke(input: {
|
|
713
|
+
revocationEndpoint: string;
|
|
714
|
+
token: string;
|
|
715
|
+
tokenTypeHint: "access_token" | "refresh_token";
|
|
716
|
+
clientId: string;
|
|
717
|
+
}): Promise<boolean> {
|
|
718
|
+
const url = metadataUrl(input.revocationEndpoint, "revocation endpoint");
|
|
719
|
+
try {
|
|
720
|
+
const { status } = await this.postForm(url, {
|
|
721
|
+
token: input.token,
|
|
722
|
+
token_type_hint: input.tokenTypeHint,
|
|
723
|
+
client_id: input.clientId,
|
|
724
|
+
});
|
|
725
|
+
return status >= 200 && status < 400;
|
|
726
|
+
} catch {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Whether an MCP failure is the server asking for authorization, and where it
|
|
734
|
+
* said its metadata lives. `McpClient` throws a typed error for a 401, so this
|
|
735
|
+
* is a narrowing rather than a second classifier of the same header.
|
|
736
|
+
*/
|
|
737
|
+
export function mcpAuthorizationRequiredV1(
|
|
738
|
+
error: unknown,
|
|
739
|
+
): { resourceMetadataUrl?: string } | undefined {
|
|
740
|
+
if (!(error instanceof McpAuthorizationRequiredError)) return undefined;
|
|
741
|
+
return error.resourceMetadataUrl
|
|
742
|
+
? { resourceMetadataUrl: error.resourceMetadataUrl }
|
|
743
|
+
: {};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Re-exported: the challenge parser lives beside the client that meets it. */
|
|
747
|
+
export const parseResourceMetadataChallengeV1 = mcpResourceMetadataChallengeV1;
|