@opengeni/network 0.2.3 → 0.3.0-canary.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/README.md +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +396 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-oauth-discovery.d.ts +79 -0
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/mcp-oauth-discovery.ts +563 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export type McpOAuthDiscoveryMode = "rfc9728_protected_resource" | "legacy_2025_03_26_metadata";
|
|
4
|
+
|
|
5
|
+
export type McpOAuthDiscoveryClassification =
|
|
6
|
+
| "oauth_rfc9728"
|
|
7
|
+
| "oauth_legacy_same_origin_metadata"
|
|
8
|
+
| "oauth_legacy_default_endpoints_unverified"
|
|
9
|
+
| "oauth_requires_profile"
|
|
10
|
+
| "oauth_discovery_broken";
|
|
11
|
+
|
|
12
|
+
export type McpOAuthChallenge = {
|
|
13
|
+
scheme: "bearer" | "oauth" | null;
|
|
14
|
+
resourceMetadata?: string;
|
|
15
|
+
scope: string[];
|
|
16
|
+
error?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type McpProtectedResourceMetadata = {
|
|
20
|
+
resource?: string;
|
|
21
|
+
authorizationServers: string[];
|
|
22
|
+
scopesSupported: string[];
|
|
23
|
+
raw: Record<string, unknown>;
|
|
24
|
+
metadataUrl: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type McpAuthorizationServerMetadata = {
|
|
28
|
+
issuer: string;
|
|
29
|
+
authorizationServer: string;
|
|
30
|
+
authorizationEndpoint: string;
|
|
31
|
+
tokenEndpoint: string;
|
|
32
|
+
registrationEndpoint?: string;
|
|
33
|
+
clientIdMetadataDocumentSupported: boolean;
|
|
34
|
+
tokenEndpointAuthMethodsSupported: string[];
|
|
35
|
+
codeChallengeMethodsSupported: string[];
|
|
36
|
+
raw: Record<string, unknown>;
|
|
37
|
+
metadataUrl: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type McpOAuthMetadataKind = "protected_resource" | "authorization_server";
|
|
41
|
+
|
|
42
|
+
export type McpOAuthMetadataFetchResult =
|
|
43
|
+
| {
|
|
44
|
+
status: "present";
|
|
45
|
+
url: string;
|
|
46
|
+
document: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
status: "absent";
|
|
50
|
+
url: string;
|
|
51
|
+
httpStatus: 404 | 410;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type McpOAuthDiscoveryResult = {
|
|
55
|
+
mode: McpOAuthDiscoveryMode;
|
|
56
|
+
classification: Extract<
|
|
57
|
+
McpOAuthDiscoveryClassification,
|
|
58
|
+
"oauth_rfc9728" | "oauth_legacy_same_origin_metadata"
|
|
59
|
+
>;
|
|
60
|
+
challenge: McpOAuthChallenge;
|
|
61
|
+
resource: string;
|
|
62
|
+
protectedResourceMetadata: McpProtectedResourceMetadata;
|
|
63
|
+
authorizationServerMetadata: McpAuthorizationServerMetadata;
|
|
64
|
+
provenance: {
|
|
65
|
+
protectedResourceMetadataUrl: string | null;
|
|
66
|
+
authorizationServerMetadataUrl: string;
|
|
67
|
+
metadataSha256: string;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export class McpOAuthDiscoveryError extends Error {
|
|
72
|
+
constructor(
|
|
73
|
+
readonly stage: "protected_resource_metadata" | "authorization_server_metadata",
|
|
74
|
+
readonly classification: Exclude<
|
|
75
|
+
McpOAuthDiscoveryClassification,
|
|
76
|
+
"oauth_rfc9728" | "oauth_legacy_same_origin_metadata"
|
|
77
|
+
>,
|
|
78
|
+
message: string,
|
|
79
|
+
readonly cause?: unknown,
|
|
80
|
+
) {
|
|
81
|
+
super(message);
|
|
82
|
+
this.name = "McpOAuthDiscoveryError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type ResolveMcpOAuthDiscoveryInput = {
|
|
87
|
+
resourceUrl: string;
|
|
88
|
+
challenge: McpOAuthChallenge;
|
|
89
|
+
fetchMetadata: (input: {
|
|
90
|
+
kind: McpOAuthMetadataKind;
|
|
91
|
+
url: string;
|
|
92
|
+
}) => Promise<McpOAuthMetadataFetchResult>;
|
|
93
|
+
validateEndpoint: (rawUrl: string, label: string) => string;
|
|
94
|
+
canonicalizeResource: (rawResource: string) => string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve modern MCP OAuth discovery first, with the 2025-03-26 same-origin
|
|
99
|
+
* metadata profile as a narrowly bounded compatibility fallback.
|
|
100
|
+
*
|
|
101
|
+
* Only an explicit 404/410 result is absence. Callers must throw for network,
|
|
102
|
+
* redirect, destination-policy, body, JSON, and other HTTP failures so none of
|
|
103
|
+
* those failures can silently downgrade a server to the legacy profile.
|
|
104
|
+
*/
|
|
105
|
+
export async function resolveMcpOAuthDiscovery(
|
|
106
|
+
input: ResolveMcpOAuthDiscoveryInput,
|
|
107
|
+
): Promise<McpOAuthDiscoveryResult> {
|
|
108
|
+
const prmCandidates = protectedResourceMetadataCandidates(
|
|
109
|
+
input.resourceUrl,
|
|
110
|
+
input.challenge.resourceMetadata,
|
|
111
|
+
);
|
|
112
|
+
let prmDocument: (McpOAuthMetadataFetchResult & { status: "present" }) | null = null;
|
|
113
|
+
for (const candidate of prmCandidates) {
|
|
114
|
+
const fetched = await input.fetchMetadata({ kind: "protected_resource", url: candidate });
|
|
115
|
+
if (fetched.status === "absent") continue;
|
|
116
|
+
prmDocument = fetched;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (prmDocument) {
|
|
121
|
+
const prm = parseProtectedResourceMetadata(prmDocument, input);
|
|
122
|
+
const authorizationServer = prm.authorizationServers[0]!;
|
|
123
|
+
const as = await discoverAuthorizationServerMetadata(authorizationServer, "modern", input);
|
|
124
|
+
return discoveryResult({
|
|
125
|
+
mode: "rfc9728_protected_resource",
|
|
126
|
+
classification: "oauth_rfc9728",
|
|
127
|
+
challenge: input.challenge,
|
|
128
|
+
resource: prm.resource ?? input.canonicalizeResource(input.resourceUrl),
|
|
129
|
+
prm,
|
|
130
|
+
as,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (input.challenge.resourceMetadata !== undefined) {
|
|
135
|
+
throw new McpOAuthDiscoveryError(
|
|
136
|
+
"protected_resource_metadata",
|
|
137
|
+
"oauth_discovery_broken",
|
|
138
|
+
"MCP advertised protected resource metadata, but no metadata document was found",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (!input.challenge.scheme) {
|
|
142
|
+
throw new McpOAuthDiscoveryError(
|
|
143
|
+
"protected_resource_metadata",
|
|
144
|
+
"oauth_discovery_broken",
|
|
145
|
+
"MCP protected resource metadata was absent and the server returned no Bearer/OAuth challenge",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const resource = input.canonicalizeResource(input.resourceUrl);
|
|
150
|
+
const resourceOrigin = new URL(resource).origin;
|
|
151
|
+
const as = await discoverAuthorizationServerMetadata(resourceOrigin, "legacy", input);
|
|
152
|
+
if (new URL(as.issuer).origin !== resourceOrigin) {
|
|
153
|
+
throw new McpOAuthDiscoveryError(
|
|
154
|
+
"authorization_server_metadata",
|
|
155
|
+
"oauth_requires_profile",
|
|
156
|
+
"legacy MCP OAuth discovery requires the authorization server issuer to share the MCP server origin",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const syntheticPrm: McpProtectedResourceMetadata = {
|
|
160
|
+
resource,
|
|
161
|
+
authorizationServers: [as.issuer],
|
|
162
|
+
scopesSupported: [...input.challenge.scope],
|
|
163
|
+
raw: {
|
|
164
|
+
resource,
|
|
165
|
+
authorization_servers: [as.issuer],
|
|
166
|
+
scopes_supported: [...input.challenge.scope],
|
|
167
|
+
},
|
|
168
|
+
metadataUrl: "",
|
|
169
|
+
};
|
|
170
|
+
return discoveryResult({
|
|
171
|
+
mode: "legacy_2025_03_26_metadata",
|
|
172
|
+
classification: "oauth_legacy_same_origin_metadata",
|
|
173
|
+
challenge: input.challenge,
|
|
174
|
+
resource,
|
|
175
|
+
prm: syntheticPrm,
|
|
176
|
+
as,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function parseMcpOAuthChallenge(header: string | null): McpOAuthChallenge {
|
|
181
|
+
if (!header) return { scheme: null, scope: [] };
|
|
182
|
+
const challenges = parseAuthenticateChallenges(header);
|
|
183
|
+
if (!challenges) return { scheme: null, scope: [] };
|
|
184
|
+
const oauthChallenges = challenges
|
|
185
|
+
.filter((candidate) => {
|
|
186
|
+
const scheme = candidate.scheme.toLowerCase();
|
|
187
|
+
return scheme === "bearer" || scheme === "oauth";
|
|
188
|
+
})
|
|
189
|
+
.map(parseOAuthChallenge);
|
|
190
|
+
return (
|
|
191
|
+
oauthChallenges.find((challenge) => challenge.resourceMetadata !== undefined) ??
|
|
192
|
+
oauthChallenges[0] ?? { scheme: null, scope: [] }
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseOAuthChallenge(challenge: {
|
|
197
|
+
scheme: string;
|
|
198
|
+
parameterParts: string[];
|
|
199
|
+
}): McpOAuthChallenge {
|
|
200
|
+
const scheme = challenge.scheme.toLowerCase() as "bearer" | "oauth";
|
|
201
|
+
const resourceMetadataPresent = challenge.parameterParts.some((part) =>
|
|
202
|
+
/^resource_metadata\s*=/i.test(part.trim()),
|
|
203
|
+
);
|
|
204
|
+
const paramsText = challenge.parameterParts.join(",");
|
|
205
|
+
const params: Record<string, string> = {};
|
|
206
|
+
const re = /([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*("(?:[^"\\]|\\.)*"|[^,\s]+)/g;
|
|
207
|
+
let match: RegExpExecArray | null;
|
|
208
|
+
while ((match = re.exec(paramsText)) !== null) {
|
|
209
|
+
const raw = match[2]!;
|
|
210
|
+
params[match[1]!.toLowerCase()] = raw.startsWith('"')
|
|
211
|
+
? raw.slice(1, -1).replace(/\\"/g, '"')
|
|
212
|
+
: raw;
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
scheme,
|
|
216
|
+
scope: params.scope ? params.scope.split(/\s+/).filter(Boolean) : [],
|
|
217
|
+
...(resourceMetadataPresent ? { resourceMetadata: params.resource_metadata ?? "" } : {}),
|
|
218
|
+
...(params.error ? { error: params.error } : {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const MAX_EMPTY_AUTHENTICATE_LIST_ELEMENTS = 8;
|
|
223
|
+
|
|
224
|
+
function parseAuthenticateChallenges(
|
|
225
|
+
header: string,
|
|
226
|
+
): Array<{ scheme: string; parameterParts: string[] }> | null {
|
|
227
|
+
const segments = splitAuthenticateHeader(header);
|
|
228
|
+
if (!segments) return null;
|
|
229
|
+
const challenges: Array<{ scheme: string; parameterParts: string[] }> = [];
|
|
230
|
+
let emptyElements = 0;
|
|
231
|
+
for (const segment of segments) {
|
|
232
|
+
const trimmed = segment.trim();
|
|
233
|
+
if (!trimmed) {
|
|
234
|
+
emptyElements += 1;
|
|
235
|
+
if (emptyElements > MAX_EMPTY_AUTHENTICATE_LIST_ELEMENTS) return null;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const token = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+/.exec(trimmed)?.[0];
|
|
239
|
+
if (!token) return null;
|
|
240
|
+
let cursor = token.length;
|
|
241
|
+
while (/\s/.test(trimmed[cursor] ?? "")) cursor += 1;
|
|
242
|
+
if (trimmed[cursor] === "=") {
|
|
243
|
+
const current = challenges.at(-1);
|
|
244
|
+
if (!current) return null;
|
|
245
|
+
current.parameterParts.push(trimmed);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const remainder = trimmed.slice(token.length);
|
|
249
|
+
if (remainder && !/^\s/.test(remainder)) return null;
|
|
250
|
+
challenges.push({
|
|
251
|
+
scheme: token,
|
|
252
|
+
parameterParts: remainder.trim() ? [remainder.trim()] : [],
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return challenges;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function splitAuthenticateHeader(header: string): string[] | null {
|
|
259
|
+
const segments: string[] = [];
|
|
260
|
+
let segmentStart = 0;
|
|
261
|
+
let quoted = false;
|
|
262
|
+
let escaped = false;
|
|
263
|
+
for (let index = 0; index < header.length; index += 1) {
|
|
264
|
+
const character = header[index]!;
|
|
265
|
+
if (quoted) {
|
|
266
|
+
if (escaped) {
|
|
267
|
+
escaped = false;
|
|
268
|
+
} else if (character === "\\") {
|
|
269
|
+
escaped = true;
|
|
270
|
+
} else if (character === '"') {
|
|
271
|
+
quoted = false;
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (character === '"') {
|
|
276
|
+
quoted = true;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (character === ",") {
|
|
280
|
+
segments.push(header.slice(segmentStart, index));
|
|
281
|
+
segmentStart = index + 1;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (quoted) return null;
|
|
285
|
+
segments.push(header.slice(segmentStart));
|
|
286
|
+
return segments;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function protectedResourceMetadataCandidates(
|
|
290
|
+
resourceUrl: string,
|
|
291
|
+
advertisedUrl?: string,
|
|
292
|
+
): string[] {
|
|
293
|
+
return uniqueStrings([
|
|
294
|
+
...(advertisedUrl !== undefined ? [advertisedUrl] : []),
|
|
295
|
+
...oauthWellKnownCandidates(resourceUrl, "oauth-protected-resource"),
|
|
296
|
+
]);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function authorizationServerMetadataCandidates(authorizationServer: string): string[] {
|
|
300
|
+
return uniqueStrings([
|
|
301
|
+
...oauthWellKnownCandidates(authorizationServer, "oauth-authorization-server"),
|
|
302
|
+
...oauthWellKnownCandidates(authorizationServer, "openid-configuration"),
|
|
303
|
+
authorizationServer,
|
|
304
|
+
]);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function legacyAuthorizationServerMetadataCandidates(resourceUrl: string): string[] {
|
|
308
|
+
const origin = new URL(resourceUrl).origin;
|
|
309
|
+
return [`${origin}/.well-known/oauth-authorization-server`];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function oauthWellKnownCandidates(rawUrl: string, name: string): string[] {
|
|
313
|
+
const url = new URL(rawUrl);
|
|
314
|
+
const path = url.pathname.replace(/^\/+|\/+$/g, "");
|
|
315
|
+
return uniqueStrings([
|
|
316
|
+
`${url.origin}/.well-known/${name}${path ? `/${path}` : ""}`,
|
|
317
|
+
`${url.origin}${path ? `/${path}` : ""}/.well-known/${name}`,
|
|
318
|
+
`${url.origin}/.well-known/${name}`,
|
|
319
|
+
]);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function parseProtectedResourceMetadata(
|
|
323
|
+
fetched: McpOAuthMetadataFetchResult & { status: "present" },
|
|
324
|
+
input: ResolveMcpOAuthDiscoveryInput,
|
|
325
|
+
): McpProtectedResourceMetadata {
|
|
326
|
+
const authorizationServers = stringArray(fetched.document.authorization_servers).map((value) =>
|
|
327
|
+
validateDiscoveryEndpoint(
|
|
328
|
+
input,
|
|
329
|
+
value,
|
|
330
|
+
"OAuth authorization server",
|
|
331
|
+
"protected_resource_metadata",
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
if (authorizationServers.length === 0) {
|
|
335
|
+
throw new McpOAuthDiscoveryError(
|
|
336
|
+
"protected_resource_metadata",
|
|
337
|
+
"oauth_discovery_broken",
|
|
338
|
+
"MCP protected resource metadata did not advertise an authorization server",
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const resourceValue = stringValue(fetched.document.resource);
|
|
342
|
+
return {
|
|
343
|
+
authorizationServers,
|
|
344
|
+
scopesSupported: stringArray(fetched.document.scopes_supported),
|
|
345
|
+
raw: fetched.document,
|
|
346
|
+
metadataUrl: fetched.url,
|
|
347
|
+
...(resourceValue ? { resource: input.canonicalizeResource(resourceValue) } : {}),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function discoverAuthorizationServerMetadata(
|
|
352
|
+
authorizationServer: string,
|
|
353
|
+
profile: "modern" | "legacy",
|
|
354
|
+
input: ResolveMcpOAuthDiscoveryInput,
|
|
355
|
+
): Promise<McpAuthorizationServerMetadata> {
|
|
356
|
+
const safeAuthorizationServer = validateDiscoveryEndpoint(
|
|
357
|
+
input,
|
|
358
|
+
authorizationServer,
|
|
359
|
+
"OAuth authorization server",
|
|
360
|
+
"authorization_server_metadata",
|
|
361
|
+
).replace(/\/+$/, "");
|
|
362
|
+
const candidates =
|
|
363
|
+
profile === "legacy"
|
|
364
|
+
? legacyAuthorizationServerMetadataCandidates(safeAuthorizationServer)
|
|
365
|
+
: authorizationServerMetadataCandidates(safeAuthorizationServer);
|
|
366
|
+
let fetched: (McpOAuthMetadataFetchResult & { status: "present" }) | null = null;
|
|
367
|
+
for (const candidate of candidates) {
|
|
368
|
+
const result = await input.fetchMetadata({ kind: "authorization_server", url: candidate });
|
|
369
|
+
if (result.status === "absent") continue;
|
|
370
|
+
fetched = result;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
if (!fetched) {
|
|
374
|
+
throw new McpOAuthDiscoveryError(
|
|
375
|
+
"authorization_server_metadata",
|
|
376
|
+
profile === "legacy" ? "oauth_legacy_default_endpoints_unverified" : "oauth_discovery_broken",
|
|
377
|
+
profile === "legacy"
|
|
378
|
+
? "legacy MCP OAuth metadata was absent; default authorization endpoints require explicit verification"
|
|
379
|
+
: "could not discover OAuth authorization server metadata",
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const authorizationEndpoint = requiredString(
|
|
384
|
+
fetched.document.authorization_endpoint,
|
|
385
|
+
"authorization_endpoint",
|
|
386
|
+
"authorization_server_metadata",
|
|
387
|
+
);
|
|
388
|
+
const tokenEndpoint = requiredString(
|
|
389
|
+
fetched.document.token_endpoint,
|
|
390
|
+
"token_endpoint",
|
|
391
|
+
"authorization_server_metadata",
|
|
392
|
+
);
|
|
393
|
+
const issuerValue = stringValue(fetched.document.issuer);
|
|
394
|
+
if (profile === "legacy" && !issuerValue) {
|
|
395
|
+
throw new McpOAuthDiscoveryError(
|
|
396
|
+
"authorization_server_metadata",
|
|
397
|
+
"oauth_discovery_broken",
|
|
398
|
+
"legacy MCP OAuth metadata did not include issuer",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
const issuer = validateDiscoveryEndpoint(
|
|
402
|
+
input,
|
|
403
|
+
issuerValue ?? safeAuthorizationServer,
|
|
404
|
+
"OAuth issuer",
|
|
405
|
+
"authorization_server_metadata",
|
|
406
|
+
);
|
|
407
|
+
if (
|
|
408
|
+
profile === "legacy" &&
|
|
409
|
+
normalizedIssuerIdentifier(issuer) !== normalizedIssuerIdentifier(safeAuthorizationServer)
|
|
410
|
+
) {
|
|
411
|
+
const crossOrigin = new URL(issuer).origin !== new URL(safeAuthorizationServer).origin;
|
|
412
|
+
throw new McpOAuthDiscoveryError(
|
|
413
|
+
"authorization_server_metadata",
|
|
414
|
+
crossOrigin ? "oauth_requires_profile" : "oauth_discovery_broken",
|
|
415
|
+
"OAuth authorization server metadata issuer did not match the selected authorization server",
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
const registrationEndpoint = stringValue(fetched.document.registration_endpoint);
|
|
419
|
+
const parsed: McpAuthorizationServerMetadata = {
|
|
420
|
+
issuer,
|
|
421
|
+
authorizationServer: safeAuthorizationServer,
|
|
422
|
+
authorizationEndpoint: validateDiscoveryEndpoint(
|
|
423
|
+
input,
|
|
424
|
+
authorizationEndpoint,
|
|
425
|
+
"OAuth authorization endpoint",
|
|
426
|
+
"authorization_server_metadata",
|
|
427
|
+
),
|
|
428
|
+
tokenEndpoint: validateDiscoveryEndpoint(
|
|
429
|
+
input,
|
|
430
|
+
tokenEndpoint,
|
|
431
|
+
"OAuth token endpoint",
|
|
432
|
+
"authorization_server_metadata",
|
|
433
|
+
),
|
|
434
|
+
clientIdMetadataDocumentSupported:
|
|
435
|
+
fetched.document.client_id_metadata_document_supported === true,
|
|
436
|
+
tokenEndpointAuthMethodsSupported: stringArray(
|
|
437
|
+
fetched.document.token_endpoint_auth_methods_supported,
|
|
438
|
+
),
|
|
439
|
+
codeChallengeMethodsSupported: stringArray(fetched.document.code_challenge_methods_supported),
|
|
440
|
+
raw: fetched.document,
|
|
441
|
+
metadataUrl: fetched.url,
|
|
442
|
+
...(registrationEndpoint
|
|
443
|
+
? {
|
|
444
|
+
registrationEndpoint: validateDiscoveryEndpoint(
|
|
445
|
+
input,
|
|
446
|
+
registrationEndpoint,
|
|
447
|
+
"OAuth registration endpoint",
|
|
448
|
+
"authorization_server_metadata",
|
|
449
|
+
),
|
|
450
|
+
}
|
|
451
|
+
: {}),
|
|
452
|
+
};
|
|
453
|
+
if (!parsed.codeChallengeMethodsSupported.includes("S256")) {
|
|
454
|
+
throw new McpOAuthDiscoveryError(
|
|
455
|
+
"authorization_server_metadata",
|
|
456
|
+
"oauth_discovery_broken",
|
|
457
|
+
"authorization server does not support required PKCE S256",
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
return parsed;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function discoveryResult(input: {
|
|
464
|
+
mode: McpOAuthDiscoveryMode;
|
|
465
|
+
classification: "oauth_rfc9728" | "oauth_legacy_same_origin_metadata";
|
|
466
|
+
challenge: McpOAuthChallenge;
|
|
467
|
+
resource: string;
|
|
468
|
+
prm: McpProtectedResourceMetadata;
|
|
469
|
+
as: McpAuthorizationServerMetadata;
|
|
470
|
+
}): McpOAuthDiscoveryResult {
|
|
471
|
+
const protectedResourceMetadataUrl = input.prm.metadataUrl || null;
|
|
472
|
+
return {
|
|
473
|
+
mode: input.mode,
|
|
474
|
+
classification: input.classification,
|
|
475
|
+
challenge: input.challenge,
|
|
476
|
+
resource: input.resource,
|
|
477
|
+
protectedResourceMetadata: input.prm,
|
|
478
|
+
authorizationServerMetadata: input.as,
|
|
479
|
+
provenance: {
|
|
480
|
+
protectedResourceMetadataUrl,
|
|
481
|
+
authorizationServerMetadataUrl: input.as.metadataUrl,
|
|
482
|
+
metadataSha256: createHash("sha256")
|
|
483
|
+
.update(
|
|
484
|
+
stableJson({
|
|
485
|
+
mode: input.mode,
|
|
486
|
+
resource: input.resource,
|
|
487
|
+
challenge: input.challenge,
|
|
488
|
+
protectedResourceMetadata: {
|
|
489
|
+
url: protectedResourceMetadataUrl,
|
|
490
|
+
document: input.prm.raw,
|
|
491
|
+
},
|
|
492
|
+
authorizationServerMetadata: {
|
|
493
|
+
url: input.as.metadataUrl,
|
|
494
|
+
document: input.as.raw,
|
|
495
|
+
},
|
|
496
|
+
}),
|
|
497
|
+
)
|
|
498
|
+
.digest("hex"),
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function validateDiscoveryEndpoint(
|
|
504
|
+
input: ResolveMcpOAuthDiscoveryInput,
|
|
505
|
+
rawUrl: string,
|
|
506
|
+
label: string,
|
|
507
|
+
stage: McpOAuthDiscoveryError["stage"],
|
|
508
|
+
): string {
|
|
509
|
+
try {
|
|
510
|
+
return input.validateEndpoint(rawUrl, label);
|
|
511
|
+
} catch (error) {
|
|
512
|
+
throw new McpOAuthDiscoveryError(
|
|
513
|
+
stage,
|
|
514
|
+
"oauth_discovery_broken",
|
|
515
|
+
error instanceof Error ? error.message : `${label} was invalid`,
|
|
516
|
+
error,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function normalizedIssuerIdentifier(value: string): string {
|
|
522
|
+
return value.replace(/\/+$/, "");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function stableJson(value: unknown): string {
|
|
526
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
527
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
528
|
+
const record = value as Record<string, unknown>;
|
|
529
|
+
return `{${Object.keys(record)
|
|
530
|
+
.sort()
|
|
531
|
+
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
|
|
532
|
+
.join(",")}}`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function uniqueStrings(values: string[]): string[] {
|
|
536
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function stringArray(value: unknown): string[] {
|
|
540
|
+
return Array.isArray(value)
|
|
541
|
+
? uniqueStrings(value.filter((entry): entry is string => typeof entry === "string"))
|
|
542
|
+
: [];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function stringValue(value: unknown): string | undefined {
|
|
546
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function requiredString(
|
|
550
|
+
value: unknown,
|
|
551
|
+
field: string,
|
|
552
|
+
stage: McpOAuthDiscoveryError["stage"],
|
|
553
|
+
): string {
|
|
554
|
+
const parsed = stringValue(value);
|
|
555
|
+
if (!parsed) {
|
|
556
|
+
throw new McpOAuthDiscoveryError(
|
|
557
|
+
stage,
|
|
558
|
+
"oauth_discovery_broken",
|
|
559
|
+
`OAuth metadata did not include ${field}`,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
return parsed;
|
|
563
|
+
}
|