@frockbot/plugin-mcp 0.0.0 → 0.1.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/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;