drupal-mcp-connector 2.6.0 → 2.7.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/.claude/commands/drupal-list-sites.md +2 -2
- package/CHANGELOG.md +63 -0
- package/README.md +3 -1
- package/config/config.example.json +16 -1
- package/package.json +4 -3
- package/src/index.js +108 -21
- package/src/lib/config.js +42 -1
- package/src/lib/dispatch.js +36 -10
- package/src/lib/http-auth.js +541 -3
- package/src/lib/http-handler.js +62 -9
- package/src/lib/load-secrets.js +158 -0
- package/src/lib/mcp-server.js +32 -6
- package/src/lib/principal.js +372 -0
- package/src/lib/verify.js +40 -4
- package/src/tools/config.js +6 -0
- package/src/tools/site.js +51 -6
package/src/lib/http-auth.js
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Inbound authentication for the HTTPS MCP transport.
|
|
3
|
+
*
|
|
4
|
+
* Two modes, never mixed on a governed product path:
|
|
5
|
+
* - Shared bearer (`makeBearerCheck`) — loopback / stdio-adjacent only.
|
|
6
|
+
* - OAuth resource server (`createResourceAuthenticator`) — network-facing
|
|
7
|
+
* HTTPS. JWT via issuer discovery + JWKS, optional RFC 7662 introspection,
|
|
8
|
+
* and a hot-reloaded revocation file. Caller-supplied identity headers
|
|
9
|
+
* never become the principal.
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
12
|
import { timingSafeEqual } from "crypto";
|
|
13
|
+
import { readFileSync, statSync } from "fs";
|
|
14
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
15
|
+
|
|
16
|
+
/** Header names that must never become identity. */
|
|
17
|
+
export const SPOOFABLE_IDENTITY_HEADERS = Object.freeze([
|
|
18
|
+
"x-mcp-subject",
|
|
19
|
+
"x-mcp-actor",
|
|
20
|
+
"x-mcp-user",
|
|
21
|
+
"x-forwarded-user",
|
|
22
|
+
"x-forwarded-sub",
|
|
23
|
+
]);
|
|
8
24
|
|
|
9
25
|
/**
|
|
10
26
|
* Build a predicate that validates an HTTP Authorization header against an
|
|
@@ -25,3 +41,525 @@ export function makeBearerCheck(token) {
|
|
|
25
41
|
return provided.length === expected.length && timingSafeEqual(provided, expected);
|
|
26
42
|
};
|
|
27
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract the raw bearer token from an Authorization header.
|
|
47
|
+
* @param {unknown} authorizationHeader
|
|
48
|
+
* @returns {?string}
|
|
49
|
+
*/
|
|
50
|
+
export function parseBearerToken(authorizationHeader) {
|
|
51
|
+
if (typeof authorizationHeader !== "string") return null;
|
|
52
|
+
if (!authorizationHeader.startsWith("Bearer ")) return null;
|
|
53
|
+
const token = authorizationHeader.slice("Bearer ".length).trim();
|
|
54
|
+
return token || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Normalize a JWT `scope` / `scp` claim to a string list.
|
|
59
|
+
* @param {unknown} scope
|
|
60
|
+
* @returns {string[]}
|
|
61
|
+
*/
|
|
62
|
+
export function scopesFromClaim(scope) {
|
|
63
|
+
if (Array.isArray(scope)) return scope.map((item) => String(item)).filter(Boolean);
|
|
64
|
+
if (typeof scope === "string") return scope.split(/[\s,]+/).filter(Boolean);
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Freeze a request identity from validated claims. Caller headers are not an input.
|
|
70
|
+
* @param {object} claims
|
|
71
|
+
* @returns {Readonly<{sub: ?string, iss: ?string, aud: string|string[]|null, scopes: readonly string[], sites: readonly string[]|null, exp: ?number, nbf: ?number, jti: ?string, clientId: ?string}>}
|
|
72
|
+
*/
|
|
73
|
+
export function buildIdentity(claims) {
|
|
74
|
+
const scopes = scopesFromClaim(claims.scope ?? claims.scp);
|
|
75
|
+
const aud = claims.aud;
|
|
76
|
+
const siteClaim = claims.sites ?? claims.mcp_sites;
|
|
77
|
+
let sites = null;
|
|
78
|
+
if (Array.isArray(siteClaim)) {
|
|
79
|
+
sites = Object.freeze(siteClaim.map(String).filter(Boolean));
|
|
80
|
+
} else if (typeof siteClaim === "string") {
|
|
81
|
+
sites = Object.freeze(siteClaim.split(/[\s,]+/).filter(Boolean));
|
|
82
|
+
}
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
sub: claims.sub === undefined || claims.sub === null ? null : String(claims.sub),
|
|
85
|
+
iss: claims.iss === undefined || claims.iss === null ? null : String(claims.iss),
|
|
86
|
+
aud: Array.isArray(aud)
|
|
87
|
+
? Object.freeze(aud.map(String))
|
|
88
|
+
: (aud === undefined || aud === null ? null : String(aud)),
|
|
89
|
+
scopes: Object.freeze([...scopes]),
|
|
90
|
+
sites,
|
|
91
|
+
exp: typeof claims.exp === "number" ? claims.exp : null,
|
|
92
|
+
nbf: typeof claims.nbf === "number" ? claims.nbf : null,
|
|
93
|
+
jti: claims.jti === undefined || claims.jti === null ? null : String(claims.jti),
|
|
94
|
+
clientId: (claims.azp === undefined || claims.azp === null)
|
|
95
|
+
&& (claims.client_id === undefined || claims.client_id === null)
|
|
96
|
+
? null
|
|
97
|
+
: String(claims.azp ?? claims.client_id),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {{scopes?: readonly string[]}} identity
|
|
103
|
+
* @param {string[]} required
|
|
104
|
+
* @returns {boolean}
|
|
105
|
+
*/
|
|
106
|
+
export function identityHasScopes(identity, required) {
|
|
107
|
+
if (!required?.length) return true;
|
|
108
|
+
const have = new Set(identity.scopes ?? []);
|
|
109
|
+
return required.every((scope) => have.has(scope));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function escapeAuthParam(value) {
|
|
113
|
+
return String(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build a RFC 6750 / RFC 9728 WWW-Authenticate value.
|
|
118
|
+
* @param {object} [fields]
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
export function formatWwwAuthenticate({
|
|
122
|
+
realm = "mcp",
|
|
123
|
+
error,
|
|
124
|
+
errorDescription,
|
|
125
|
+
scope,
|
|
126
|
+
resourceMetadata,
|
|
127
|
+
} = {}) {
|
|
128
|
+
const parts = [`Bearer realm="${realm}"`];
|
|
129
|
+
if (error) parts.push(`error="${error}"`);
|
|
130
|
+
if (errorDescription) parts.push(`error_description="${escapeAuthParam(errorDescription)}"`);
|
|
131
|
+
if (scope) parts.push(`scope="${scope}"`);
|
|
132
|
+
if (resourceMetadata) parts.push(`resource_metadata="${resourceMetadata}"`);
|
|
133
|
+
return parts.join(", ");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* RFC 9728 Protected Resource Metadata document.
|
|
138
|
+
* @param {object} fields
|
|
139
|
+
* @returns {object}
|
|
140
|
+
*/
|
|
141
|
+
export function protectedResourceMetadata({
|
|
142
|
+
resource,
|
|
143
|
+
authorizationServers,
|
|
144
|
+
scopesSupported = [],
|
|
145
|
+
}) {
|
|
146
|
+
return {
|
|
147
|
+
resource,
|
|
148
|
+
authorization_servers: [...authorizationServers],
|
|
149
|
+
bearer_methods_supported: ["header"],
|
|
150
|
+
scopes_supported: [...scopesSupported],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* RFC 9728 well-known URL for a resource identifier.
|
|
156
|
+
* @param {string} resource
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
export function resourceMetadataUrlFor(resource) {
|
|
160
|
+
const url = new URL(resource);
|
|
161
|
+
const trimmed = url.pathname.replace(/\/+$/, "");
|
|
162
|
+
const suffix = trimmed === "" || trimmed === "/" ? "" : trimmed.replace(/^\//, "");
|
|
163
|
+
url.pathname = suffix
|
|
164
|
+
? `/.well-known/oauth-protected-resource/${suffix}`
|
|
165
|
+
: "/.well-known/oauth-protected-resource";
|
|
166
|
+
url.search = "";
|
|
167
|
+
url.hash = "";
|
|
168
|
+
return url.toString();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {number} status
|
|
173
|
+
* @param {object} fields
|
|
174
|
+
* @returns {{ok: false, status: number, headers: object, body: string}}
|
|
175
|
+
*/
|
|
176
|
+
export function denyAuth(status, {
|
|
177
|
+
error,
|
|
178
|
+
errorDescription,
|
|
179
|
+
scope,
|
|
180
|
+
resourceMetadata,
|
|
181
|
+
body,
|
|
182
|
+
} = {}) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
status,
|
|
186
|
+
headers: {
|
|
187
|
+
"WWW-Authenticate": formatWwwAuthenticate({
|
|
188
|
+
error,
|
|
189
|
+
errorDescription,
|
|
190
|
+
scope,
|
|
191
|
+
resourceMetadata,
|
|
192
|
+
}),
|
|
193
|
+
},
|
|
194
|
+
body: body ?? (status === 403 ? "Forbidden" : "Unauthorized"),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {object} identity
|
|
200
|
+
* @returns {{ok: true, identity: object}}
|
|
201
|
+
*/
|
|
202
|
+
export function allowAuth(identity) {
|
|
203
|
+
return { ok: true, identity };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function audienceMatches(aud, expected) {
|
|
207
|
+
if (aud === undefined || aud === null) return false;
|
|
208
|
+
if (Array.isArray(aud)) return aud.includes(expected);
|
|
209
|
+
return aud === expected;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function normalizeIssuer(issuer) {
|
|
213
|
+
return String(issuer).replace(/\/+$/, "");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isHttpsUrl(value) {
|
|
217
|
+
try {
|
|
218
|
+
return new URL(value).protocol === "https:";
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* RFC 8414 inserts `.well-known` between host and path. OIDC Discovery
|
|
226
|
+
* appends `/.well-known/openid-configuration` to the issuer identifier.
|
|
227
|
+
* @param {string} issuer
|
|
228
|
+
* @returns {string[]}
|
|
229
|
+
*/
|
|
230
|
+
export function authorizationServerDiscoveryUrls(issuer) {
|
|
231
|
+
const url = new URL(issuer);
|
|
232
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
233
|
+
const hasPath = path && path !== "/";
|
|
234
|
+
const rfc8414 = `${url.origin}/.well-known/oauth-authorization-server${hasPath ? path : ""}`;
|
|
235
|
+
const oidc = `${normalizeIssuer(issuer)}/.well-known/openid-configuration`;
|
|
236
|
+
return [rfc8414, oidc];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Hot-reloaded revocation list. The file is re-read when its mtime changes, so
|
|
241
|
+
* a revoke takes effect without restarting the process.
|
|
242
|
+
*
|
|
243
|
+
* Shape: `{ "jti": ["…"], "sub": ["…"] }`.
|
|
244
|
+
*
|
|
245
|
+
* @param {object} options
|
|
246
|
+
* @param {string} [options.filePath]
|
|
247
|
+
* @returns {{isRevoked: (identity: {jti?: ?string, sub?: ?string}) => boolean}}
|
|
248
|
+
*/
|
|
249
|
+
export function createRevocationStore({
|
|
250
|
+
filePath,
|
|
251
|
+
readFile = readFileSync,
|
|
252
|
+
stat = statSync,
|
|
253
|
+
} = {}) {
|
|
254
|
+
let cache = { mtimeMs: Number.NaN, jti: new Set(), sub: new Set(), denyAll: false };
|
|
255
|
+
|
|
256
|
+
function load() {
|
|
257
|
+
if (!filePath) return cache;
|
|
258
|
+
let info;
|
|
259
|
+
try {
|
|
260
|
+
info = stat(filePath);
|
|
261
|
+
} catch {
|
|
262
|
+
cache = { mtimeMs: Number.NaN, jti: new Set(), sub: new Set(), denyAll: false };
|
|
263
|
+
return cache;
|
|
264
|
+
}
|
|
265
|
+
if (info.mtimeMs === cache.mtimeMs) return cache;
|
|
266
|
+
try {
|
|
267
|
+
const raw = JSON.parse(readFile(filePath, "utf8"));
|
|
268
|
+
cache = {
|
|
269
|
+
mtimeMs: info.mtimeMs,
|
|
270
|
+
jti: new Set((raw.jti ?? []).map(String)),
|
|
271
|
+
sub: new Set((raw.sub ?? []).map(String)),
|
|
272
|
+
denyAll: false,
|
|
273
|
+
};
|
|
274
|
+
} catch {
|
|
275
|
+
// File exists but is unreadable or not JSON — fail closed.
|
|
276
|
+
cache = { mtimeMs: info.mtimeMs, jti: new Set(), sub: new Set(), denyAll: true };
|
|
277
|
+
}
|
|
278
|
+
return cache;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
isRevoked(identity) {
|
|
283
|
+
const { jti, sub, denyAll } = load();
|
|
284
|
+
if (denyAll) return true;
|
|
285
|
+
if (identity.jti && jti.has(identity.jti)) return true;
|
|
286
|
+
if (identity.sub && sub.has(identity.sub)) return true;
|
|
287
|
+
return false;
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Fetch RFC 8414 / OIDC authorization-server metadata for an issuer.
|
|
294
|
+
* @param {string} issuer
|
|
295
|
+
* @param {typeof fetch} [fetchFn]
|
|
296
|
+
* @returns {Promise<object>}
|
|
297
|
+
*/
|
|
298
|
+
export async function discoverAuthorizationServer(issuer, fetchFn = fetch) {
|
|
299
|
+
if (!isHttpsUrl(issuer)) {
|
|
300
|
+
throw new Error(`Authorization server issuer must be HTTPS: ${issuer}`);
|
|
301
|
+
}
|
|
302
|
+
const expected = normalizeIssuer(issuer);
|
|
303
|
+
for (const url of authorizationServerDiscoveryUrls(issuer)) {
|
|
304
|
+
let res;
|
|
305
|
+
try {
|
|
306
|
+
res = await fetchFn(url, { headers: { accept: "application/json" } });
|
|
307
|
+
} catch {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (!res.ok) continue;
|
|
311
|
+
let body;
|
|
312
|
+
try {
|
|
313
|
+
body = await res.json();
|
|
314
|
+
} catch {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
// RFC 8414 §3.3: metadata issuer must match the identifier we queried.
|
|
318
|
+
if (normalizeIssuer(body.issuer) !== expected) continue;
|
|
319
|
+
if (!isHttpsUrl(body.jwks_uri)) continue;
|
|
320
|
+
return body;
|
|
321
|
+
}
|
|
322
|
+
throw new Error(`Authorization server metadata not found for issuer ${issuer}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* RFC 7662 token introspection. Returns claims when active, otherwise null.
|
|
327
|
+
* @param {string} token
|
|
328
|
+
* @param {object} options
|
|
329
|
+
* @returns {Promise<object|null>}
|
|
330
|
+
*/
|
|
331
|
+
export async function introspectToken(token, {
|
|
332
|
+
url,
|
|
333
|
+
clientId,
|
|
334
|
+
clientSecret,
|
|
335
|
+
fetchFn = fetch,
|
|
336
|
+
}) {
|
|
337
|
+
const creds = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
|
338
|
+
const res = await fetchFn(url, {
|
|
339
|
+
method: "POST",
|
|
340
|
+
headers: {
|
|
341
|
+
authorization: `Basic ${creds}`,
|
|
342
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
343
|
+
accept: "application/json",
|
|
344
|
+
},
|
|
345
|
+
body: new URLSearchParams({ token }).toString(),
|
|
346
|
+
});
|
|
347
|
+
if (!res.ok) return null;
|
|
348
|
+
const body = await res.json();
|
|
349
|
+
if (body.active !== true) return null;
|
|
350
|
+
return body;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Authenticate one inbound request as an OAuth protected resource.
|
|
355
|
+
*
|
|
356
|
+
* `requestHeaders` is accepted so callers can pass the full header map; those
|
|
357
|
+
* headers are deliberately unused. Identity comes only from the validated token.
|
|
358
|
+
*
|
|
359
|
+
* @param {object} options
|
|
360
|
+
* @returns {(authorizationHeader: unknown, requestHeaders?: object) => Promise<object>}
|
|
361
|
+
*/
|
|
362
|
+
export function createResourceAuthenticator({
|
|
363
|
+
issuer,
|
|
364
|
+
audience,
|
|
365
|
+
requiredScopes = [],
|
|
366
|
+
resourceMetadataUrl,
|
|
367
|
+
jwks,
|
|
368
|
+
verifyJwt,
|
|
369
|
+
introspect,
|
|
370
|
+
revocationStore,
|
|
371
|
+
clockTolerance = 5,
|
|
372
|
+
}) {
|
|
373
|
+
const fail = (status, extra) => denyAuth(status, { resourceMetadata: resourceMetadataUrl, ...extra });
|
|
374
|
+
const expectedIssuer = issuer ? normalizeIssuer(issuer) : "";
|
|
375
|
+
|
|
376
|
+
async function claimsFromJwt(token) {
|
|
377
|
+
if (verifyJwt) return verifyJwt(token);
|
|
378
|
+
if (!jwks) throw new Error("missing JWKS");
|
|
379
|
+
const { payload } = await jwtVerify(token, jwks, {
|
|
380
|
+
issuer: expectedIssuer ? [expectedIssuer, `${expectedIssuer}/`] : undefined,
|
|
381
|
+
audience,
|
|
382
|
+
clockTolerance,
|
|
383
|
+
});
|
|
384
|
+
return payload;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return async function authenticate(authorizationHeader, requestHeaders = {}) {
|
|
388
|
+
void requestHeaders;
|
|
389
|
+
const token = parseBearerToken(authorizationHeader);
|
|
390
|
+
if (!token) {
|
|
391
|
+
return fail(401, { error: "invalid_token", errorDescription: "Bearer token required" });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let claims = null;
|
|
395
|
+
let jwtOk = false;
|
|
396
|
+
try {
|
|
397
|
+
claims = await claimsFromJwt(token);
|
|
398
|
+
jwtOk = true;
|
|
399
|
+
} catch {
|
|
400
|
+
claims = null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!jwtOk) {
|
|
404
|
+
if (!introspect) {
|
|
405
|
+
return fail(401, { error: "invalid_token", errorDescription: "Token validation failed" });
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
claims = await introspect(token);
|
|
409
|
+
} catch {
|
|
410
|
+
claims = null;
|
|
411
|
+
}
|
|
412
|
+
if (!claims) {
|
|
413
|
+
return fail(401, { error: "invalid_token", errorDescription: "Token is not active" });
|
|
414
|
+
}
|
|
415
|
+
} else if (introspect) {
|
|
416
|
+
let active;
|
|
417
|
+
try {
|
|
418
|
+
active = await introspect(token);
|
|
419
|
+
} catch {
|
|
420
|
+
active = null;
|
|
421
|
+
}
|
|
422
|
+
if (!active) {
|
|
423
|
+
return fail(401, { error: "invalid_token", errorDescription: "Token is not active" });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (expectedIssuer && claims.iss && normalizeIssuer(claims.iss) !== expectedIssuer) {
|
|
428
|
+
return fail(401, { error: "invalid_token", errorDescription: "Issuer mismatch" });
|
|
429
|
+
}
|
|
430
|
+
if (audience && !audienceMatches(claims.aud, audience)) {
|
|
431
|
+
return fail(401, { error: "invalid_token", errorDescription: "Audience mismatch" });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const identity = buildIdentity(claims);
|
|
435
|
+
if (revocationStore?.isRevoked(identity)) {
|
|
436
|
+
return fail(401, { error: "invalid_token", errorDescription: "Token has been revoked" });
|
|
437
|
+
}
|
|
438
|
+
if (!identityHasScopes(identity, requiredScopes)) {
|
|
439
|
+
return fail(403, {
|
|
440
|
+
error: "insufficient_scope",
|
|
441
|
+
scope: requiredScopes.join(" "),
|
|
442
|
+
errorDescription: "Required scope is missing",
|
|
443
|
+
body: "Forbidden",
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return allowAuth(identity);
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Decide how inbound HTTPS /mcp is authenticated.
|
|
452
|
+
*
|
|
453
|
+
* Network-facing product paths require a resource server. MCP_AUTH_TOKEN is
|
|
454
|
+
* accepted only on loopback (or when the operator has opted into an unauthenticated
|
|
455
|
+
* trusted-proxy front).
|
|
456
|
+
*
|
|
457
|
+
* @param {object} options
|
|
458
|
+
* @returns {{mode: "resource_server"|"shared_bearer"|"unauthenticated"|"fatal", reason?: string}}
|
|
459
|
+
*/
|
|
460
|
+
export function resolveInboundAuthMode({
|
|
461
|
+
bindHost,
|
|
462
|
+
allowUnauth = false,
|
|
463
|
+
sharedToken = "",
|
|
464
|
+
resourceServer = null,
|
|
465
|
+
}) {
|
|
466
|
+
const isLoopback = bindHost === "127.0.0.1" || bindHost === "::1" || bindHost === "localhost";
|
|
467
|
+
const hasRs = Boolean(resourceServer?.issuer && resourceServer?.audience);
|
|
468
|
+
if (hasRs) return { mode: "resource_server" };
|
|
469
|
+
if (allowUnauth) return { mode: "unauthenticated" };
|
|
470
|
+
if (isLoopback) {
|
|
471
|
+
return sharedToken ? { mode: "shared_bearer" } : { mode: "unauthenticated" };
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
mode: "fatal",
|
|
475
|
+
reason:
|
|
476
|
+
"Network-facing HTTPS requires an inbound OAuth resource server (auth.issuer and auth.audience). " +
|
|
477
|
+
"MCP_AUTH_TOKEN is not accepted on governed product paths. " +
|
|
478
|
+
"Bind to 127.0.0.1, set MCP_ALLOW_UNAUTHENTICATED=1 behind a trusted proxy, or configure the resource server.",
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Merge config.auth with environment overrides.
|
|
484
|
+
* @param {object} [cfg]
|
|
485
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
486
|
+
* @returns {object}
|
|
487
|
+
*/
|
|
488
|
+
export function resolveInboundAuthConfig(cfg = {}, env = process.env) {
|
|
489
|
+
const auth = cfg.auth && typeof cfg.auth === "object" ? cfg.auth : {};
|
|
490
|
+
const issuer = env.MCP_RESOURCE_ISSUER || auth.issuer || "";
|
|
491
|
+
const audience = env.MCP_RESOURCE_AUDIENCE || auth.audience || "";
|
|
492
|
+
const resource = env.MCP_RESOURCE || auth.resource || (String(audience).startsWith("https://") ? audience : "");
|
|
493
|
+
return {
|
|
494
|
+
issuer,
|
|
495
|
+
audience,
|
|
496
|
+
resource,
|
|
497
|
+
requiredScopes: Array.isArray(auth.requiredScopes) ? auth.requiredScopes.map(String) : [],
|
|
498
|
+
revocationFile: env.MCP_REVOCATION_FILE || auth.revocationFile || "",
|
|
499
|
+
introspectionUrl: env.MCP_INTROSPECTION_URL || auth.introspectionUrl || "",
|
|
500
|
+
introspectionClientIdEnv: auth.introspectionClientIdEnv || "",
|
|
501
|
+
introspectionClientSecretEnv: auth.introspectionClientSecretEnv || "",
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Build the live inbound authenticator for HTTPS.
|
|
507
|
+
* @param {object} options
|
|
508
|
+
* @returns {Promise<{authenticate: Function, protectedResource: object, resourceMetadataUrl: string}>}
|
|
509
|
+
*/
|
|
510
|
+
export async function createInboundHttpsAuth({ inboundCfg, fetchFn = fetch }) {
|
|
511
|
+
if (!inboundCfg.issuer || !inboundCfg.audience) {
|
|
512
|
+
throw new Error("createInboundHttpsAuth requires issuer and audience");
|
|
513
|
+
}
|
|
514
|
+
const resource = inboundCfg.resource || inboundCfg.audience;
|
|
515
|
+
if (!isHttpsUrl(resource)) {
|
|
516
|
+
throw new Error("auth.resource (or auth.audience) must be an https URL");
|
|
517
|
+
}
|
|
518
|
+
if (!isHttpsUrl(inboundCfg.issuer)) {
|
|
519
|
+
throw new Error("auth.issuer must be an https URL");
|
|
520
|
+
}
|
|
521
|
+
if (inboundCfg.introspectionUrl && !isHttpsUrl(inboundCfg.introspectionUrl)) {
|
|
522
|
+
throw new Error("auth.introspectionUrl must be an https URL");
|
|
523
|
+
}
|
|
524
|
+
const issuer = normalizeIssuer(inboundCfg.issuer);
|
|
525
|
+
const asMeta = await discoverAuthorizationServer(issuer, fetchFn);
|
|
526
|
+
const advertisedIssuer = asMeta.issuer || issuer;
|
|
527
|
+
const jwks = createRemoteJWKSet(new URL(asMeta.jwks_uri));
|
|
528
|
+
const resourceMetadataUrl = resourceMetadataUrlFor(resource);
|
|
529
|
+
const revocationStore = inboundCfg.revocationFile
|
|
530
|
+
? createRevocationStore({ filePath: inboundCfg.revocationFile })
|
|
531
|
+
: null;
|
|
532
|
+
|
|
533
|
+
let introspect;
|
|
534
|
+
if (inboundCfg.introspectionUrl) {
|
|
535
|
+
const env = new Map(Object.entries(process.env));
|
|
536
|
+
const idKey = inboundCfg.introspectionClientIdEnv;
|
|
537
|
+
const secretKey = inboundCfg.introspectionClientSecretEnv;
|
|
538
|
+
introspect = (token) => introspectToken(token, {
|
|
539
|
+
url: inboundCfg.introspectionUrl,
|
|
540
|
+
clientId: idKey ? env.get(idKey) || "" : "",
|
|
541
|
+
clientSecret: secretKey ? env.get(secretKey) || "" : "",
|
|
542
|
+
fetchFn,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const check = createResourceAuthenticator({
|
|
547
|
+
issuer,
|
|
548
|
+
audience: inboundCfg.audience,
|
|
549
|
+
requiredScopes: inboundCfg.requiredScopes,
|
|
550
|
+
resourceMetadataUrl,
|
|
551
|
+
jwks,
|
|
552
|
+
introspect,
|
|
553
|
+
revocationStore,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
authenticate: (req) => check(req.headers.authorization, req.headers),
|
|
558
|
+
protectedResource: protectedResourceMetadata({
|
|
559
|
+
resource,
|
|
560
|
+
authorizationServers: [advertisedIssuer],
|
|
561
|
+
scopesSupported: inboundCfg.requiredScopes,
|
|
562
|
+
}),
|
|
563
|
+
resourceMetadataUrl,
|
|
564
|
+
};
|
|
565
|
+
}
|
package/src/lib/http-handler.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
11
|
import { isInitializeRequest, isJsonContentType, isLegacyRequest } from "@modelcontextprotocol/server";
|
|
12
12
|
import { NodeStreamableHTTPServerTransport, toWebRequest } from "@modelcontextprotocol/node";
|
|
13
|
+
import { formatWwwAuthenticate } from "./http-auth.js";
|
|
14
|
+
import { runWithIdentity } from "./principal.js";
|
|
13
15
|
|
|
14
16
|
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
15
17
|
|
|
@@ -143,7 +145,10 @@ export function createLegacySessionHandler({
|
|
|
143
145
|
* - everything else — 404.
|
|
144
146
|
*
|
|
145
147
|
* @param {object} deps
|
|
146
|
-
* @param {(authHeader: any) => boolean} deps.checkAuth
|
|
148
|
+
* @param {(authHeader: any) => boolean} [deps.checkAuth] Shared-bearer predicate (loopback).
|
|
149
|
+
* @param {(req: import("http").IncomingMessage) => Promise<object>} [deps.authenticate]
|
|
150
|
+
* Resource-server authenticator. When set, it replaces `checkAuth`.
|
|
151
|
+
* @param {object|null} [deps.protectedResource] RFC 9728 metadata served unauthenticated.
|
|
147
152
|
* @param {number} deps.toolCount Tool count reported by /health.
|
|
148
153
|
* @param {(req: object, res: object, body?: unknown) => Promise<void>} deps.modernHandler
|
|
149
154
|
* @param {(req: object, res: object, body?: unknown) => Promise<void>} deps.legacyHandler
|
|
@@ -158,7 +163,10 @@ export function createLegacySessionHandler({
|
|
|
158
163
|
* @returns {(req: import("http").IncomingMessage, res: import("http").ServerResponse) => Promise<void>}
|
|
159
164
|
*/
|
|
160
165
|
export function createMcpRequestHandler({
|
|
161
|
-
checkAuth
|
|
166
|
+
checkAuth = () => true,
|
|
167
|
+
authenticate = null,
|
|
168
|
+
protectedResource = null,
|
|
169
|
+
toolCount,
|
|
162
170
|
modernHandler = null,
|
|
163
171
|
legacyHandler = null,
|
|
164
172
|
rateLimiter = null,
|
|
@@ -172,7 +180,54 @@ export function createMcpRequestHandler({
|
|
|
172
180
|
throw new Error("modernHandler and legacyHandler must be configured together for dual-era routing");
|
|
173
181
|
}
|
|
174
182
|
|
|
183
|
+
function requestPath(req) {
|
|
184
|
+
return String(req.url || "").split("?")[0];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function gateAuth(req, res) {
|
|
188
|
+
if (authenticate) {
|
|
189
|
+
let result;
|
|
190
|
+
try {
|
|
191
|
+
result = await authenticate(req);
|
|
192
|
+
} catch {
|
|
193
|
+
res.writeHead(401, {
|
|
194
|
+
"WWW-Authenticate": formatWwwAuthenticate({
|
|
195
|
+
error: "invalid_token",
|
|
196
|
+
errorDescription: "Token validation failed",
|
|
197
|
+
}),
|
|
198
|
+
}).end("Unauthorized");
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
if (!result.ok) {
|
|
202
|
+
res.writeHead(result.status, result.headers).end(result.body);
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
req.mcpIdentity = result.identity;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
if (!checkAuth(req.headers["authorization"])) {
|
|
209
|
+
res.writeHead(401, { "WWW-Authenticate": "Bearer" }).end("Unauthorized");
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
175
215
|
return async function handle(req, res) {
|
|
216
|
+
const path = requestPath(req);
|
|
217
|
+
if (
|
|
218
|
+
req.method === "GET"
|
|
219
|
+
&& (path === "/.well-known/oauth-protected-resource"
|
|
220
|
+
|| path.startsWith("/.well-known/oauth-protected-resource/"))
|
|
221
|
+
) {
|
|
222
|
+
if (!protectedResource) {
|
|
223
|
+
res.writeHead(404).end("Not found");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
res.writeHead(200, { "Content-Type": "application/json" })
|
|
227
|
+
.end(JSON.stringify(protectedResource));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
176
231
|
if (req.url === "/mcp" && ["POST", "GET", "DELETE"].includes(req.method)) {
|
|
177
232
|
// Rate limit BEFORE auth so repeated bad-token attempts are throttled too.
|
|
178
233
|
if (rateLimiter) {
|
|
@@ -182,16 +237,14 @@ export function createMcpRequestHandler({
|
|
|
182
237
|
return;
|
|
183
238
|
}
|
|
184
239
|
}
|
|
185
|
-
// Auth gate: only the /mcp endpoint requires a token; /health
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
240
|
+
// Auth gate: only the /mcp endpoint requires a token; /health and
|
|
241
|
+
// protected-resource metadata stay open.
|
|
242
|
+
if (!await gateAuth(req, res)) return;
|
|
190
243
|
}
|
|
191
244
|
|
|
192
245
|
if (req.url === "/mcp" && (req.method === "GET" || req.method === "DELETE")) {
|
|
193
246
|
try {
|
|
194
|
-
await legacyHandler(req, res);
|
|
247
|
+
await runWithIdentity(req.mcpIdentity ?? null, () => legacyHandler(req, res));
|
|
195
248
|
} catch (error) {
|
|
196
249
|
respondAfterFailure(res, error, onError, "legacy-dispatch");
|
|
197
250
|
}
|
|
@@ -212,7 +265,7 @@ export function createMcpRequestHandler({
|
|
|
212
265
|
const legacy = await isLegacyRequestFn(request, body);
|
|
213
266
|
const selectedHandler = legacy ? legacyHandler : modernHandler;
|
|
214
267
|
stage = legacy ? "legacy-dispatch" : "modern-dispatch";
|
|
215
|
-
await selectedHandler(req, res, body);
|
|
268
|
+
await runWithIdentity(req.mcpIdentity ?? null, () => selectedHandler(req, res, body));
|
|
216
269
|
} catch (error) {
|
|
217
270
|
respondAfterFailure(res, error, onError, stage);
|
|
218
271
|
}
|