@fruggr/zendesk-mcp-server 2.23.0 → 3.0.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.
@@ -0,0 +1,1383 @@
1
+ #!/usr/bin/env node
2
+ import { a as configDir, c as requestedScope, d as getOAuthUrls, f as silentLogger, i as deadlineSignal, l as supportedScopes, n as zendeskGet, o as generateCodeChallenge, s as generateCodeVerifier, t as createMcpServer, u as escapeHtml } from "./index.js";
3
+ import { createHash, createPrivateKey, createSecretKey, hkdfSync, randomBytes, randomUUID } from "node:crypto";
4
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
+ import { createServer } from "node:http";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
9
+ import { CompactEncrypt, compactDecrypt, decodeProtectedHeader } from "jose";
10
+ import Provider, { errors, interactionPolicy } from "oidc-provider";
11
+ //#region src/auth/server/expiring-map.ts
12
+ /**
13
+ * An in-memory map whose entries lapse after their own TTL. Expired entries are
14
+ * swept on every `set`, so the map stays bounded by what was set within the
15
+ * longest TTL, with no timer to hold the process open.
16
+ */
17
+ const createExpiringMap = () => {
18
+ const entries = /* @__PURE__ */ new Map();
19
+ const get = (key) => {
20
+ const entry = entries.get(key);
21
+ return entry && entry.expiresAt > Date.now() ? entry.value : void 0;
22
+ };
23
+ return {
24
+ set: (key, value, ttlMs) => {
25
+ const now = Date.now();
26
+ for (const [k, entry] of entries) if (entry.expiresAt <= now) entries.delete(k);
27
+ entries.set(key, {
28
+ value,
29
+ expiresAt: now + ttlMs
30
+ });
31
+ },
32
+ get,
33
+ /** Read and forget: a pending value is used at most once. */
34
+ take: (key) => {
35
+ const value = get(key);
36
+ entries.delete(key);
37
+ return value;
38
+ },
39
+ /** Live and not-yet-swept entries. */
40
+ size: () => entries.size
41
+ };
42
+ };
43
+ //#endregion
44
+ //#region src/auth/server/consent.ts
45
+ const hostOf = (uri) => {
46
+ try {
47
+ return new URL(uri).host;
48
+ } catch {
49
+ return uri;
50
+ }
51
+ };
52
+ const SCOPE_LABELS = {
53
+ read: "Read your Zendesk data",
54
+ write: "Create and change Zendesk data on your behalf"
55
+ };
56
+ /**
57
+ * The MCP consent screen, shown once per client that is not trusted (DCR,
58
+ * unknown CIMD, loopback redirects). Names the client, the scopes and where the
59
+ * code goes, per the spec's UI requirements. Every interpolated value is
60
+ * escaped: the client name comes from a document anyone can publish.
61
+ */
62
+ const renderConsentPage = (view) => {
63
+ const name = escapeHtml(view.clientName ?? view.clientId);
64
+ const scopes = view.scopes.map((scope) => `<li>${escapeHtml(SCOPE_LABELS[scope] ?? scope)}</li>`).join("");
65
+ const action = `/interaction/${encodeURIComponent(view.uid)}`;
66
+ return [
67
+ "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">",
68
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">",
69
+ "<title>Authorize access to Zendesk</title>",
70
+ "<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem;line-height:1.5}",
71
+ "code{word-break:break-all}button{font:inherit;padding:.5rem 1.25rem;margin-right:.5rem}</style>",
72
+ "</head><body>",
73
+ `<h1>Allow ${name} to use your Zendesk account?</h1>`,
74
+ `<p><strong>${name}</strong> is asking to act as you on <strong>${escapeHtml(view.subdomain)}.zendesk.com</strong>:</p>`,
75
+ `<ul>${scopes}</ul>`,
76
+ `<p>After you allow it, you are sent back to <strong>${escapeHtml(hostOf(view.redirectUri))}</strong>`,
77
+ ` (<code>${escapeHtml(view.redirectUri)}</code>). Only continue if you started this connection yourself.</p>`,
78
+ `<p>Client id: <code>${escapeHtml(view.clientId)}</code></p>`,
79
+ `<form method="post" action="${action}/confirm" style="display:inline"><button type="submit">Allow</button></form>`,
80
+ `<form method="post" action="${action}/abort" style="display:inline"><button type="submit">Deny</button></form>`,
81
+ "</body></html>"
82
+ ].join("");
83
+ };
84
+ /** Plain error page for a failed sign-in. ASCII message, escaped. */
85
+ const renderErrorPage = (title, detail) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Sign-in failed</title></head><body><h1>${escapeHtml(title)}</h1><p>${escapeHtml(detail)}</p></body></html>`;
86
+ //#endregion
87
+ //#region src/auth/server/trusted-clients.ts
88
+ /**
89
+ * Which CIMD clients skip our consent screen, and the fetch shim applied to CIMD
90
+ * documents. Policy and seed list: `docs/decisions/oauth-authorization-server.md`
91
+ * ("Consent") and the #127 plan (§2).
92
+ */
93
+ const DEFAULT_TRUSTED_CLIENTS = ["https://claude.ai/oauth/mcp-oauth-client-metadata", "https://chatgpt.com/oauth/client.json"];
94
+ const trustedClientSet = (extra, includeDefaults) => /* @__PURE__ */ new Set([...includeDefaults ? DEFAULT_TRUSTED_CLIENTS : [], ...extra]);
95
+ const IPV4_LOOPBACK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
96
+ const isLoopbackHost = (hostname) => hostname === "localhost" || hostname === "[::1]" || IPV4_LOOPBACK.test(hostname);
97
+ const parse = (uri) => URL.canParse(uri) ? new URL(uri) : void 0;
98
+ /** HTTPS and not loopback: a code sent there cannot land on the user's machine. */
99
+ const isSkipEligibleRedirect = (uri) => {
100
+ const url = parse(uri);
101
+ return url !== void 0 && url.protocol === "https:" && !isLoopbackHost(url.hostname);
102
+ };
103
+ /**
104
+ * Skip the consent screen only for an allowlisted client id, asking for a
105
+ * redirect URI its own document lists, over HTTPS and off loopback. The
106
+ * document is served from the client's own domain, so the code cannot be
107
+ * diverted to an attacker.
108
+ */
109
+ const canSkipConsent = (request, trusted) => trusted.has(request.clientId) && request.registeredRedirectUris.includes(request.redirectUri) && isSkipEligibleRedirect(request.redirectUri);
110
+ const isLoopbackHttp = (uri) => {
111
+ const url = typeof uri === "string" ? parse(uri) : void 0;
112
+ return url !== void 0 && url.protocol === "http:" && isLoopbackHost(url.hostname);
113
+ };
114
+ /**
115
+ * Some CIMD documents (Claude Code, Zed) list only loopback redirect URIs but do
116
+ * not declare `application_type: native`, so oidc-provider matches the port
117
+ * exactly and rejects the random port the client listens on (RFC 8252 §7.3 says
118
+ * any port). Such a document is a native app by every other sign.
119
+ */
120
+ const inferNativeApplication = (document) => {
121
+ if (!document || typeof document !== "object" || Array.isArray(document)) return document;
122
+ const { application_type: applicationType, redirect_uris: redirectUris } = document;
123
+ const allLoopback = Array.isArray(redirectUris) && redirectUris.length > 0 && redirectUris.every(isLoopbackHttp);
124
+ return applicationType === void 0 && allLoopback ? {
125
+ ...document,
126
+ application_type: "native"
127
+ } : document;
128
+ };
129
+ const MAX_INSPECTED_BYTES = 16384;
130
+ const readCapped = async (response) => {
131
+ const reader = response.body?.getReader();
132
+ if (!reader) return /* @__PURE__ */ new Uint8Array();
133
+ const chunks = [];
134
+ let total = 0;
135
+ while (total <= MAX_INSPECTED_BYTES) {
136
+ const { done, value } = await reader.read();
137
+ if (done) break;
138
+ chunks.push(value);
139
+ total += value.length;
140
+ }
141
+ await reader.cancel().catch(() => void 0);
142
+ return Buffer.concat(chunks);
143
+ };
144
+ /**
145
+ * Wrap oidc-provider's `fetch` so CIMD documents go through
146
+ * `inferNativeApplication`. The request options are passed through untouched:
147
+ * they carry the library's SSRF-guarded dispatcher, timeout and redirect policy.
148
+ * Anything that is not a client document (a JWKS, an error) is returned as read.
149
+ */
150
+ const createCimdFetch = (baseFetch) => async (url, options) => {
151
+ const response = await baseFetch(url, options);
152
+ if (!response.ok) return response;
153
+ const bytes = await readCapped(response);
154
+ const rebuilt = (body) => new Response(body, {
155
+ status: response.status,
156
+ headers: response.headers
157
+ });
158
+ if (bytes.length > MAX_INSPECTED_BYTES) return rebuilt(Buffer.from(bytes));
159
+ let parsed;
160
+ try {
161
+ parsed = JSON.parse(Buffer.from(bytes).toString("utf8"));
162
+ } catch {}
163
+ if (!(typeof parsed?.client_id === "string")) return rebuilt(Buffer.from(bytes));
164
+ const headers = new Headers(response.headers);
165
+ headers.delete("content-length");
166
+ return new Response(JSON.stringify(inferNativeApplication(parsed)), {
167
+ status: response.status,
168
+ headers
169
+ });
170
+ };
171
+ //#endregion
172
+ //#region src/auth/server/upstream.ts
173
+ /**
174
+ * Zendesk as the upstream identity provider, through the same public PKCE client
175
+ * the stdio flow uses. The server runs PKCE itself; no client secret exists.
176
+ * Why: `docs/decisions/oauth-authorization-server.md` ("Upstream login").
177
+ */
178
+ const ZENDESK_ACCESS_TOKEN_TTL_S = 172800;
179
+ const ZENDESK_REFRESH_TOKEN_TTL_S = 7776e3;
180
+ /** ASCII-only (auth paths end up in headers); never carries the Zendesk body. */
181
+ const upstreamAuthError = (message, status, cause) => Object.assign(new Error(message, cause === void 0 ? void 0 : { cause }), {
182
+ name: "UpstreamAuthError",
183
+ ...status === void 0 ? {} : { status }
184
+ });
185
+ const isUpstreamAuthError = (err) => err instanceof Error && err.name === "UpstreamAuthError";
186
+ const buildZendeskAuthorizeUrl = (upstream, params) => {
187
+ const url = new URL(getOAuthUrls(upstream.subdomain).authorizeUrl);
188
+ url.searchParams.set("response_type", "code");
189
+ url.searchParams.set("client_id", upstream.clientId);
190
+ url.searchParams.set("redirect_uri", params.redirectUri);
191
+ url.searchParams.set("scope", params.scope);
192
+ url.searchParams.set("state", params.state);
193
+ url.searchParams.set("code_challenge", params.codeChallenge);
194
+ url.searchParams.set("code_challenge_method", "S256");
195
+ return url.toString();
196
+ };
197
+ const toTokenSet = (result, previousRefreshToken) => ({
198
+ accessToken: result.access_token,
199
+ refreshToken: result.refresh_token ?? previousRefreshToken,
200
+ expiresAt: result.expires_in === void 0 ? void 0 : Date.now() + result.expires_in * 1e3,
201
+ scope: result.scope
202
+ });
203
+ const requestTokens = async (upstream, grant, logger) => {
204
+ const body = new URLSearchParams({
205
+ client_id: upstream.clientId,
206
+ expires_in: String(ZENDESK_ACCESS_TOKEN_TTL_S),
207
+ refresh_token_expires_in: String(ZENDESK_REFRESH_TOKEN_TTL_S),
208
+ ...grant
209
+ });
210
+ let response;
211
+ try {
212
+ response = await fetch(getOAuthUrls(upstream.subdomain).tokenUrl, {
213
+ method: "POST",
214
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
215
+ body: body.toString(),
216
+ signal: deadlineSignal(upstream.timeoutMs ?? 3e4)
217
+ });
218
+ } catch (err) {
219
+ throw upstreamAuthError("Zendesk token endpoint unreachable.", void 0, err);
220
+ }
221
+ logger.debug("oauth_upstream_token", {
222
+ grant: grant["grant_type"],
223
+ status: response.status
224
+ });
225
+ if (!response.ok) throw upstreamAuthError(`Zendesk refused the ${grant["grant_type"]} grant (${response.status}).`, response.status);
226
+ return await response.json();
227
+ };
228
+ const exchangeZendeskCode = async (upstream, params, logger = silentLogger) => toTokenSet(await requestTokens(upstream, {
229
+ grant_type: "authorization_code",
230
+ code: params.code,
231
+ redirect_uri: params.redirectUri,
232
+ code_verifier: params.codeVerifier
233
+ }, logger));
234
+ /** Zendesk rotates on refresh: the caller must persist the returned set. */
235
+ const refreshZendeskTokens = async (upstream, refreshToken, logger = silentLogger) => toTokenSet(await requestTokens(upstream, {
236
+ grant_type: "refresh_token",
237
+ refresh_token: refreshToken
238
+ }, logger), refreshToken);
239
+ /** Zendesk is not OIDC: the account is whoever `/users/me` says the token belongs to. */
240
+ const fetchZendeskIdentity = async (upstream, accessToken) => {
241
+ const failure = (cause) => upstreamAuthError("Could not resolve the Zendesk account of the signed-in user.", void 0, cause);
242
+ let user;
243
+ try {
244
+ ({user} = await zendeskGet(upstream.subdomain, accessToken, "/users/me"));
245
+ } catch (err) {
246
+ throw failure(err);
247
+ }
248
+ if (typeof user?.id !== "number") throw failure();
249
+ return {
250
+ id: user.id,
251
+ name: user.name,
252
+ role: user.role
253
+ };
254
+ };
255
+ //#endregion
256
+ //#region src/auth/server/interactions.ts
257
+ const PENDING_TTL_MS = 6e5;
258
+ const CONSENT_MEMORY_TTL_S = 7776e3;
259
+ const sendHtml = (res, status, html) => {
260
+ res.writeHead(status, {
261
+ "Content-Type": "text/html; charset=utf-8",
262
+ "Cache-Control": "no-store"
263
+ });
264
+ res.end(html);
265
+ };
266
+ const redirect = (res, location) => {
267
+ res.writeHead(302, {
268
+ Location: location,
269
+ "Cache-Control": "no-store"
270
+ });
271
+ res.end();
272
+ };
273
+ const EXPIRED = "This sign-in has expired or was opened in another browser. Start the connection again from your MCP client.";
274
+ const consentMemoryKey = (accountId, clientId, redirectUris) => createHash("sha256").update(JSON.stringify([
275
+ accountId,
276
+ clientId,
277
+ [...redirectUris].sort()
278
+ ])).digest("base64url");
279
+ const isExpiredLink = (err) => err instanceof Error && (err.name === "SessionNotFound" || err.name === "InvalidRequest");
280
+ const UID = /^[\w-]{1,64}$/;
281
+ /**
282
+ * The login and consent steps oidc-provider hands off to us. Login is always a
283
+ * round trip to Zendesk (fresh tokens for every grant); consent is skipped for
284
+ * trusted CIMD clients and for a client this user already approved with the
285
+ * same redirect URIs, and shown otherwise (ADR, Consent).
286
+ */
287
+ const createInteractionRoutes = (deps) => {
288
+ const { provider, upstream, issuer } = deps;
289
+ const logger = deps.logger ?? silentLogger;
290
+ const callbackUrl = `${issuer}/oauth/callback`;
291
+ const verifiers = createExpiringMap();
292
+ const upstreamTokens = createExpiringMap();
293
+ const finish = (req, res, result) => provider.interactionFinished(req, res, result, { mergeWithLastSubmission: true });
294
+ const deny = (req, res, description) => finish(req, res, {
295
+ error: "access_denied",
296
+ error_description: description
297
+ });
298
+ const startUpstreamLogin = (res, uid) => {
299
+ const verifier = generateCodeVerifier();
300
+ verifiers.set(uid, verifier, PENDING_TTL_MS);
301
+ redirect(res, buildZendeskAuthorizeUrl(upstream, {
302
+ redirectUri: callbackUrl,
303
+ scope: requestedScope(deps.readOnly),
304
+ state: uid,
305
+ codeChallenge: generateCodeChallenge(verifier)
306
+ }));
307
+ };
308
+ const approve = async (req, res, details, { accountId, clientId }, tokens) => {
309
+ const grant = new provider.Grant({
310
+ accountId,
311
+ clientId
312
+ });
313
+ const missing = details.prompt.details;
314
+ if (missing.missingOIDCScope?.length) grant.addOIDCScope(missing.missingOIDCScope.join(" "));
315
+ for (const [indicator, scopes] of Object.entries(missing.missingResourceScopes ?? {})) grant.addResourceScope(indicator, scopes.join(" "));
316
+ const grantId = await grant.save();
317
+ await deps.zendeskGrants.save(grantId, tokens);
318
+ logger.info("oauth_grant_created", { clientId });
319
+ await finish(req, res, { consent: { grantId } });
320
+ };
321
+ const consentContext = async (details) => {
322
+ const accountId = String(details.session?.accountId);
323
+ const clientId = String(details.params["client_id"]);
324
+ const client = await provider.Client.find(clientId);
325
+ const redirectUris = client?.redirectUris ?? [];
326
+ return {
327
+ client,
328
+ accountId,
329
+ clientId,
330
+ pendingKey: `${accountId}|${clientId}`,
331
+ memoryKey: consentMemoryKey(accountId, clientId, redirectUris),
332
+ redirectUri: String(details.params["redirect_uri"]),
333
+ redirectUris
334
+ };
335
+ };
336
+ const showInteraction = async (req, res, uid) => {
337
+ const details = await provider.interactionDetails(req, res);
338
+ if (details.prompt.name === "login") {
339
+ startUpstreamLogin(res, uid);
340
+ return;
341
+ }
342
+ const context = await consentContext(details);
343
+ if (canSkipConsent({
344
+ clientId: context.clientId,
345
+ redirectUri: context.redirectUri,
346
+ registeredRedirectUris: context.redirectUris
347
+ }, deps.trustedClients) || await deps.consentMemory.get(context.memoryKey) === true) {
348
+ const tokens = upstreamTokens.take(context.pendingKey);
349
+ if (!tokens) return deny(req, res, "The Zendesk sign-in expired. Try again.");
350
+ return approve(req, res, details, context, tokens);
351
+ }
352
+ const missing = details.prompt.details;
353
+ const scopes = [...new Set(Object.values(missing.missingResourceScopes ?? {}).flat())];
354
+ sendHtml(res, 200, renderConsentPage({
355
+ uid,
356
+ clientName: context.client?.clientName,
357
+ clientId: context.clientId,
358
+ redirectUri: context.redirectUri,
359
+ scopes,
360
+ subdomain: upstream.subdomain
361
+ }));
362
+ };
363
+ const completeUpstreamLogin = async (req, res, uid, url) => {
364
+ const details = await provider.interactionDetails(req, res);
365
+ const verifier = verifiers.take(uid);
366
+ const code = url.searchParams.get("code");
367
+ if (!verifier || !code) return deny(req, res, "The Zendesk sign-in was cancelled or refused.");
368
+ try {
369
+ const tokens = await exchangeZendeskCode(upstream, {
370
+ code,
371
+ redirectUri: callbackUrl,
372
+ codeVerifier: verifier
373
+ }, logger);
374
+ const accountId = `zendesk:${(await fetchZendeskIdentity(upstream, tokens.accessToken)).id}`;
375
+ upstreamTokens.set(`${accountId}|${String(details.params["client_id"])}`, tokens, PENDING_TTL_MS);
376
+ await finish(req, res, { login: { accountId } });
377
+ } catch (err) {
378
+ if (!isUpstreamAuthError(err)) throw err;
379
+ logger.warn("oauth_upstream_login_failed", { error: err.message });
380
+ await deny(req, res, err.message);
381
+ }
382
+ };
383
+ const confirm = async (req, res) => {
384
+ const details = await provider.interactionDetails(req, res);
385
+ if (details.prompt.name !== "consent") return sendHtml(res, 400, renderErrorPage("Sign-in failed", EXPIRED));
386
+ const context = await consentContext(details);
387
+ const tokens = upstreamTokens.take(context.pendingKey);
388
+ if (!tokens) return deny(req, res, "The Zendesk sign-in expired. Try again.");
389
+ await deps.consentMemory.set(context.memoryKey, true, CONSENT_MEMORY_TTL_S);
390
+ return approve(req, res, details, context, tokens);
391
+ };
392
+ const routes = async (req, res, url) => {
393
+ const [, , uid = "", action] = url.pathname.split("/");
394
+ if (!UID.test(uid)) return sendHtml(res, 404, renderErrorPage("Not found", EXPIRED));
395
+ if (req.method === "GET" && action === void 0) return showInteraction(req, res, uid);
396
+ if (req.method === "GET" && action === "callback") return completeUpstreamLogin(req, res, uid, url);
397
+ if (req.method === "POST" && action === "confirm") return confirm(req, res);
398
+ if (req.method === "POST" && action === "abort") {
399
+ await provider.interactionDetails(req, res);
400
+ return deny(req, res, "The user declined access.");
401
+ }
402
+ return sendHtml(res, 404, renderErrorPage("Not found", EXPIRED));
403
+ };
404
+ return {
405
+ /** `/oauth/callback`: the one redirect URI registered at Zendesk. */
406
+ upstreamCallback: (res, url) => {
407
+ const uid = url.searchParams.get("state") ?? "";
408
+ if (!UID.test(uid)) {
409
+ sendHtml(res, 400, renderErrorPage("Sign-in failed", EXPIRED));
410
+ return;
411
+ }
412
+ const next = new URL(`/interaction/${uid}/callback`, issuer);
413
+ const code = url.searchParams.get("code");
414
+ if (code) next.searchParams.set("code", code);
415
+ redirect(res, `${next.pathname}${next.search}`);
416
+ },
417
+ /** `/interaction/<uid>[/callback|/confirm|/abort]`. */
418
+ handle: async (req, res, url) => {
419
+ try {
420
+ await routes(req, res, url);
421
+ } catch (err) {
422
+ if (!isExpiredLink(err)) throw err;
423
+ if (!res.headersSent) sendHtml(res, 400, renderErrorPage("Sign-in failed", EXPIRED));
424
+ }
425
+ }
426
+ };
427
+ };
428
+ const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
429
+ const derive = (master, purpose, length = 32) => Buffer.from(hkdfSync("sha256", master, Buffer.alloc(0), `zendesk-mcp/${purpose}/v1`, length));
430
+ const kidFor = (master, purpose) => createHash("sha256").update(derive(master, `kid/${purpose}`, 16)).digest("base64url").slice(0, 16);
431
+ /**
432
+ * Parse `OAUTH_MASTER_SECRET`: base64 entries, comma-separated, newest first.
433
+ * Errors never echo the value.
434
+ */
435
+ const parseSecretList = (raw) => {
436
+ const entries = raw.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0).map((entry) => Buffer.from(entry, "base64"));
437
+ const [first, ...rest] = entries;
438
+ if (!first) throw new Error("OAuth master secret is empty.");
439
+ if (entries.some((secret) => secret.length < 32)) throw new Error(`OAuth master secret must decode to at least 32 bytes of base64 (generate one with: openssl rand -base64 32).`);
440
+ return [first, ...rest];
441
+ };
442
+ const deriveKeySet = (master) => {
443
+ return {
444
+ signingJwk: {
445
+ ...createPrivateKey({
446
+ key: Buffer.concat([ED25519_PKCS8_PREFIX, derive(master, "signing")]),
447
+ format: "der",
448
+ type: "pkcs8"
449
+ }).export({ format: "jwk" }),
450
+ kid: kidFor(master, "signing"),
451
+ use: "sig",
452
+ alg: "EdDSA"
453
+ },
454
+ accessToken: {
455
+ kid: kidFor(master, "access-token"),
456
+ key: createSecretKey(derive(master, "access-token"))
457
+ },
458
+ atRest: {
459
+ kid: kidFor(master, "at-rest"),
460
+ key: createSecretKey(derive(master, "at-rest"))
461
+ },
462
+ cookieKey: derive(master, "cookie").toString("base64url")
463
+ };
464
+ };
465
+ const deriveKeyRing = (raw) => {
466
+ const [first, ...rest] = parseSecretList(raw);
467
+ return [deriveKeySet(first), ...rest.map(deriveKeySet)];
468
+ };
469
+ //#endregion
470
+ //#region src/auth/server/file-store.ts
471
+ const isWindows$1 = process.platform === "win32";
472
+ const isLive = (entry, now) => entry.exp === void 0 || entry.exp > now;
473
+ /**
474
+ * A store over a `Map`, calling `onChange` after every write. The map is the
475
+ * caller's, so a test can look at exactly what was stored.
476
+ */
477
+ const createMemoryStore = (entries = /* @__PURE__ */ new Map(), onChange = () => void 0) => ({
478
+ get: async (key) => {
479
+ const entry = entries.get(key);
480
+ return entry && isLive(entry, Date.now()) ? entry.v : void 0;
481
+ },
482
+ set: async (key, value, ttlMs) => {
483
+ entries.set(key, ttlMs === void 0 ? { v: value } : {
484
+ v: value,
485
+ exp: Date.now() + ttlMs
486
+ });
487
+ onChange();
488
+ },
489
+ delete: async (key) => {
490
+ if (entries.delete(key)) onChange();
491
+ }
492
+ });
493
+ const isEntry = (value) => typeof value?.v === "string" && (value.exp === void 0 || typeof value.exp === "number");
494
+ const load = (path) => {
495
+ let raw;
496
+ try {
497
+ raw = readFileSync(path, "utf8");
498
+ } catch (err) {
499
+ if (err.code === "ENOENT") return /* @__PURE__ */ new Map();
500
+ throw new Error(`Cannot read the OAuth store file (${err.code}).`, { cause: err });
501
+ }
502
+ const parsed = JSON.parse(raw);
503
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("The OAuth store file is not a JSON object.");
504
+ const now = Date.now();
505
+ return new Map(Object.entries(parsed).filter((entry) => isEntry(entry[1]) && isLive(entry[1], now)));
506
+ };
507
+ const persist = (path, entries) => {
508
+ const now = Date.now();
509
+ const live = [...entries].filter(([, entry]) => isLive(entry, now));
510
+ mkdirSync(dirname(path), {
511
+ recursive: true,
512
+ mode: 448
513
+ });
514
+ const tmp = `${path}.${process.pid}.tmp`;
515
+ writeFileSync(tmp, JSON.stringify(Object.fromEntries(live)), { mode: 384 });
516
+ if (!isWindows$1) chmodSync(tmp, 384);
517
+ renameSync(tmp, path);
518
+ };
519
+ /**
520
+ * The default store: one JSON file, written atomically on every change. Chosen
521
+ * over `keyv-file`, whose plain `writeFile` can truncate the file on a crash and
522
+ * whose loader then silently starts empty. Single process only (ADR, Storage).
523
+ */
524
+ const createFileStore = (path) => {
525
+ const entries = load(path);
526
+ return createMemoryStore(entries, () => persist(path, entries));
527
+ };
528
+ //#endregion
529
+ //#region src/auth/server/store.ts
530
+ /**
531
+ * Models that must survive a restart. Everything else (sessions, interactions,
532
+ * authorization codes, ...) stays in memory: a restart costs an in-flight login
533
+ * at most. `ZendeskTokens` is ours, keyed by grant id.
534
+ */
535
+ const PERSISTENT_MODELS = /* @__PURE__ */ new Set([
536
+ "Client",
537
+ "Grant",
538
+ "RefreshToken",
539
+ "ZendeskTokens"
540
+ ]);
541
+ const encoder = new TextEncoder();
542
+ const decoder$1 = new TextDecoder();
543
+ const hashId = (id) => createHash("sha256").update(id).digest("base64url");
544
+ const seal = (ring, value, expiresAt) => new CompactEncrypt(encoder.encode(JSON.stringify(value))).setProtectedHeader({
545
+ alg: "dir",
546
+ enc: "A256GCM",
547
+ kid: ring[0].atRest.kid,
548
+ expiresAt
549
+ }).encrypt(ring[0].atRest.key);
550
+ const unseal = async (ring, sealed) => {
551
+ const { kid, expiresAt } = decodeProtectedHeader(sealed);
552
+ const keySet = ring.find((set) => set.atRest.kid === kid);
553
+ if (!keySet) return void 0;
554
+ const { plaintext } = await compactDecrypt(sealed, keySet.atRest.key);
555
+ return {
556
+ value: JSON.parse(decoder$1.decode(plaintext)),
557
+ current: keySet === ring[0],
558
+ expiresAt
559
+ };
560
+ };
561
+ const open = (ring, sealed) => unseal(ring, sealed).catch(() => void 0);
562
+ const secondsUntil = (exp) => exp === void 0 ? void 0 : Math.max(exp - Math.floor(Date.now() / 1e3), 1);
563
+ /**
564
+ * One named collection of whole-payload-encrypted records. A record read under
565
+ * an older secret is rewritten under the current one, so dropping that secret
566
+ * later loses nothing still in use (DCR clients are otherwise never rewritten).
567
+ */
568
+ const createSealedCollection = (store, name, ring) => {
569
+ const key = (id) => `${name}:${hashId(id)}`;
570
+ return {
571
+ get: async (id) => {
572
+ const sealed = await store.get(key(id));
573
+ if (sealed === void 0) return void 0;
574
+ const record = await open(ring, sealed);
575
+ if (record && !record.current) {
576
+ const { expiresAt } = record;
577
+ const ttlMs = expiresAt === void 0 ? void 0 : expiresAt - Date.now();
578
+ await store.set(key(id), await seal(ring, record.value, expiresAt), ttlMs);
579
+ }
580
+ return record?.value;
581
+ },
582
+ set: async (id, value, ttlSeconds) => {
583
+ const ttlMs = ttlSeconds ? ttlSeconds * 1e3 : void 0;
584
+ const expiresAt = ttlMs === void 0 ? void 0 : Date.now() + ttlMs;
585
+ await store.set(key(id), await seal(ring, value, expiresAt), ttlMs);
586
+ },
587
+ delete: async (id) => {
588
+ await store.delete(key(id));
589
+ }
590
+ };
591
+ };
592
+ const createKeyLock = () => {
593
+ const tails = /* @__PURE__ */ new Map();
594
+ return (lockKey, fn) => {
595
+ const run = (tails.get(lockKey) ?? Promise.resolve()).then(fn, fn);
596
+ const tail = run.catch(() => void 0);
597
+ tails.set(lockKey, tail);
598
+ tail.then(() => {
599
+ if (tails.get(lockKey) === tail) tails.delete(lockKey);
600
+ });
601
+ return run;
602
+ };
603
+ };
604
+ /** oidc-provider adapter factory over two stores, sealed with the at-rest key. */
605
+ const createAdapterFactory = (stores, ring) => {
606
+ const memory = stores.memory ?? createMemoryStore();
607
+ const withLock = createKeyLock();
608
+ return (name) => {
609
+ const store = PERSISTENT_MODELS.has(name) ? stores.persistent : memory;
610
+ const records = createSealedCollection(store, name, ring);
611
+ const grantIndex = createSealedCollection(store, `${name}:grant`, ring);
612
+ const uidIndex = createSealedCollection(store, `${name}:uid`, ring);
613
+ const indexGrant = (grantId, id, exp) => withLock(`${name}:${grantId}`, async () => {
614
+ const current = await grantIndex.get(grantId);
615
+ const latestExp = Math.max(current?.exp ?? 0, exp ?? 0) || void 0;
616
+ const ttl = latestExp ? Math.max(latestExp - Math.floor(Date.now() / 1e3), 1) : void 0;
617
+ await grantIndex.set(grantId, {
618
+ ids: [...current?.ids ?? [], id],
619
+ exp: latestExp
620
+ }, ttl);
621
+ });
622
+ return {
623
+ upsert: async (id, payload, expiresIn) => {
624
+ await records.set(id, payload, expiresIn);
625
+ if (payload.grantId) await indexGrant(payload.grantId, id, payload.exp);
626
+ if (payload.uid) await uidIndex.set(payload.uid, id, expiresIn);
627
+ },
628
+ find: (id) => records.get(id),
629
+ findByUid: async (uid) => {
630
+ const id = await uidIndex.get(uid);
631
+ return id ? records.get(id) : void 0;
632
+ },
633
+ findByUserCode: async () => void 0,
634
+ consume: async (id) => {
635
+ const payload = await records.get(id);
636
+ if (!payload) return;
637
+ await records.set(id, {
638
+ ...payload,
639
+ consumed: Math.floor(Date.now() / 1e3)
640
+ }, secondsUntil(payload.exp));
641
+ },
642
+ destroy: (id) => records.delete(id),
643
+ revokeByGrantId: (grantId) => withLock(`${name}:${grantId}`, async () => {
644
+ const index = await grantIndex.get(grantId);
645
+ if (!index) return;
646
+ await Promise.all(index.ids.map((id) => records.delete(id)));
647
+ await grantIndex.delete(grantId);
648
+ })
649
+ };
650
+ };
651
+ };
652
+ /**
653
+ * Open the persistent store named by `--oauth-store`: `file://` (the default)
654
+ * or `memory://` (tests, throwaway runs). Another backend means implementing
655
+ * `RecordStore`; the ADR records why the store is not pluggable yet.
656
+ */
657
+ const openStore = (uri) => {
658
+ const { protocol } = new URL(uri);
659
+ if (protocol === "memory:") return createMemoryStore();
660
+ if (protocol === "file:") return createFileStore(fileURLToPath(uri));
661
+ throw new Error(`Unsupported OAuth store scheme "${protocol}". Use file:// or memory://.`);
662
+ };
663
+ const grantUnavailableError = () => Object.assign(/* @__PURE__ */ new Error("The Zendesk authorization behind this grant is no longer valid."), { name: "ZendeskGrantUnavailable" });
664
+ const isGrantUnavailableError = (err) => err instanceof Error && err.name === "ZendeskGrantUnavailable";
665
+ const isRefusal = (err) => isUpstreamAuthError(err) && (err.status === 400 || err.status === 401);
666
+ /**
667
+ * Zendesk tokens per grant of ours. Both layers rotate their refresh tokens, so
668
+ * two concurrent Zendesk refreshes would spend the same single-use token: the
669
+ * refresh is serialised per grant. Only this step is locked, never our own
670
+ * `/token` endpoint (ADR, Tokens).
671
+ */
672
+ const createZendeskGrants = (options) => {
673
+ const { records, upstream } = options;
674
+ const logger = options.logger ?? silentLogger;
675
+ const refresh = options.refresh ?? refreshZendeskTokens;
676
+ const now = options.now ?? Date.now;
677
+ const margin = options.refreshMarginMs ?? 3e5;
678
+ const withLock = createKeyLock();
679
+ const needsRefresh = (tokens) => tokens.expiresAt !== void 0 && tokens.expiresAt - now() <= margin;
680
+ const refreshed = async (grantId, refreshToken) => {
681
+ let fresh;
682
+ try {
683
+ fresh = await refresh(upstream, refreshToken, logger);
684
+ } catch (err) {
685
+ logger.warn("oauth_upstream_refresh_failed", { error: err instanceof Error ? err.message : String(err) });
686
+ if (!isRefusal(err)) throw err;
687
+ await records.delete(grantId);
688
+ throw grantUnavailableError();
689
+ }
690
+ await records.set(grantId, fresh, ZENDESK_REFRESH_TOKEN_TTL_S);
691
+ return fresh.accessToken;
692
+ };
693
+ return {
694
+ save: (grantId, tokens) => records.set(grantId, tokens, ZENDESK_REFRESH_TOKEN_TTL_S),
695
+ remove: (grantId) => withLock(grantId, () => records.delete(grantId)),
696
+ accessToken: (grantId) => withLock(grantId, async () => {
697
+ const tokens = await records.get(grantId);
698
+ if (!tokens) throw grantUnavailableError();
699
+ if (!needsRefresh(tokens)) return tokens.accessToken;
700
+ if (!tokens.refreshToken) throw grantUnavailableError();
701
+ return refreshed(grantId, tokens.refreshToken);
702
+ })
703
+ };
704
+ };
705
+ //#endregion
706
+ //#region src/auth/server/provider.ts
707
+ const ACCESS_TOKEN_TTL_S = 3600;
708
+ const GRANT_TTL_S = 7776e3;
709
+ const SESSION_TTL_S = 43200;
710
+ const INTERACTION_TTL_S = 600;
711
+ /** Scopes the server advertises and grants; `offline_access` is for clients (ChatGPT) that ask for it. */
712
+ const RESOURCE_SCOPES = ["read", "write"];
713
+ const policyWithUpstreamLogin = () => {
714
+ const policy = interactionPolicy.base();
715
+ policy.get("login")?.checks.add(new interactionPolicy.Check("upstream_login_required", "every authorization signs in to Zendesk again", (ctx) => ctx.oidc.result?.["login"] === void 0 ? interactionPolicy.Check.REQUEST_PROMPT : interactionPolicy.Check.NO_NEED_TO_PROMPT));
716
+ return policy;
717
+ };
718
+ /** The page oidc-provider shows when it cannot send the error back to the client. */
719
+ const providerErrorPage = (out) => renderErrorPage("Sign-in failed", `${out.error}: ${out.error_description ?? "the authorization request was rejected"}`);
720
+ const buildProvider = (options) => {
721
+ const { ring, resource } = options;
722
+ const [current] = ring;
723
+ const scopes = options.allowWrite ? RESOURCE_SCOPES.join(" ") : "read";
724
+ const baseFetch = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
725
+ const provider = new Provider(options.issuer, {
726
+ adapter: options.adapter,
727
+ jwks: { keys: ring.map((set) => set.signingJwk) },
728
+ cookies: { keys: ring.map((set) => set.cookieKey) },
729
+ clients: [],
730
+ fetch: createCimdFetch(baseFetch),
731
+ scopes: options.allowWrite ? [...RESOURCE_SCOPES, "offline_access"] : ["read", "offline_access"],
732
+ clientDefaults: {
733
+ grant_types: ["authorization_code", "refresh_token"],
734
+ response_types: ["code"],
735
+ token_endpoint_auth_method: "none",
736
+ id_token_signed_response_alg: "EdDSA"
737
+ },
738
+ responseTypes: ["code"],
739
+ clientAuthMethods: [
740
+ "none",
741
+ "private_key_jwt",
742
+ "client_secret_basic",
743
+ "client_secret_post"
744
+ ],
745
+ clientBasedCORS: (_ctx, origin) => options.isAllowedOrigin(origin),
746
+ pkce: { required: () => true },
747
+ issueRefreshToken: async (_ctx, client) => client.grantTypeAllowed("refresh_token"),
748
+ expiresWithSession: async () => false,
749
+ ttl: {
750
+ AccessToken: ACCESS_TOKEN_TTL_S,
751
+ AuthorizationCode: 60,
752
+ Grant: GRANT_TTL_S,
753
+ Interaction: INTERACTION_TTL_S,
754
+ RefreshToken: GRANT_TTL_S,
755
+ Session: SESSION_TTL_S
756
+ },
757
+ features: {
758
+ devInteractions: { enabled: false },
759
+ registration: { enabled: true },
760
+ revocation: { enabled: true },
761
+ userinfo: { enabled: false },
762
+ rpInitiatedLogout: { enabled: false },
763
+ clientIdMetadataDocument: {
764
+ enabled: true,
765
+ ack: "draft-02",
766
+ allowFetch: async (_ctx, clientId) => {
767
+ const url = new URL(clientId);
768
+ if (url.protocol !== "https:") return false;
769
+ return url.port === "";
770
+ }
771
+ },
772
+ resourceIndicators: {
773
+ enabled: true,
774
+ defaultResource: () => resource,
775
+ useGrantedResource: () => true,
776
+ getResourceServerInfo: (_ctx, indicator) => {
777
+ if (indicator !== resource) throw new errors.InvalidTarget();
778
+ return {
779
+ scope: scopes,
780
+ audience: resource,
781
+ accessTokenTTL: ACCESS_TOKEN_TTL_S,
782
+ accessTokenFormat: "jwt",
783
+ jwt: { encrypt: {
784
+ alg: "dir",
785
+ enc: "A256GCM",
786
+ key: current.accessToken.key,
787
+ kid: current.accessToken.kid
788
+ } }
789
+ };
790
+ }
791
+ }
792
+ },
793
+ extraTokenClaims: async (_ctx, token) => {
794
+ if (token.kind !== "AccessToken" || !token.grantId) return void 0;
795
+ try {
796
+ return {
797
+ zd: await options.zendeskGrants.accessToken(token.grantId),
798
+ gid: token.grantId
799
+ };
800
+ } catch (err) {
801
+ if (isGrantUnavailableError(err)) throw new errors.InvalidGrant({
802
+ detail: "the Zendesk authorization behind this grant is no longer valid",
803
+ cause: err
804
+ });
805
+ throw err;
806
+ }
807
+ },
808
+ findAccount: async (_ctx, sub) => ({
809
+ accountId: sub,
810
+ claims: async () => ({ sub })
811
+ }),
812
+ loadExistingGrant: async (ctx) => {
813
+ const grantId = ctx.oidc.result?.["consent"]?.grantId;
814
+ return grantId ? ctx.oidc.provider.Grant.find(grantId) : void 0;
815
+ },
816
+ interactions: {
817
+ url: (_ctx, interaction) => `/interaction/${interaction.uid}`,
818
+ policy: policyWithUpstreamLogin()
819
+ },
820
+ renderError: async (ctx, out) => {
821
+ ctx.type = "html";
822
+ ctx.body = providerErrorPage(out);
823
+ }
824
+ });
825
+ provider.proxy = options.behindProxy;
826
+ return provider;
827
+ };
828
+ //#endregion
829
+ //#region src/auth/server/secret.ts
830
+ const MASTER_SECRET_FILE_NAME = "oauth-master-secret";
831
+ const isWindows = process.platform === "win32";
832
+ const readSecretFile = (path) => {
833
+ try {
834
+ return readFileSync(path, "utf8").trim() || void 0;
835
+ } catch (err) {
836
+ if (err.code === "ENOENT") return void 0;
837
+ throw new Error(`Cannot read the OAuth master secret file (${err.code}).`, { cause: err });
838
+ }
839
+ };
840
+ const writeSecretFile = (path, value) => {
841
+ mkdirSync(dirname(path), { recursive: true });
842
+ const tmp = `${path}.${process.pid}.tmp`;
843
+ writeFileSync(tmp, `${value}\n`, { mode: 384 });
844
+ if (!isWindows) chmodSync(tmp, 384);
845
+ renameSync(tmp, path);
846
+ };
847
+ const storeDirectory = (storeUri) => storeUri.startsWith("file:") ? dirname(fileURLToPath(storeUri)) : void 0;
848
+ /**
849
+ * Resolve the AS master secret: explicit value, then `--oauth-master-secret-file`,
850
+ * then the persisted file in the config dir, then a fresh secret written there.
851
+ * The library's public `DEV_KEYSTORE` fallback is thereby never reachable.
852
+ * Rationale and custody rules: `docs/decisions/oauth-authorization-server.md`.
853
+ */
854
+ const resolveMasterSecret = (input, logger = silentLogger) => {
855
+ const validated = (value, source) => {
856
+ parseSecretList(value);
857
+ return {
858
+ value,
859
+ source
860
+ };
861
+ };
862
+ if (input.value) return validated(input.value, "env");
863
+ if (input.file) {
864
+ const fromFlag = readSecretFile(input.file);
865
+ if (!fromFlag) throw new Error("The OAuth master secret file is missing or empty.");
866
+ return validated(fromFlag, "file-flag");
867
+ }
868
+ const generate = () => randomBytes(32).toString("base64");
869
+ if (input.storeUri.startsWith("memory:")) {
870
+ logger.info("oauth_master_secret_ephemeral", { hint: "memory:// store: the secret and every grant are lost on restart." });
871
+ return {
872
+ value: generate(),
873
+ source: "ephemeral"
874
+ };
875
+ }
876
+ const path = join(input.configDir, MASTER_SECRET_FILE_NAME);
877
+ const persisted = readSecretFile(path);
878
+ const secret = persisted ? validated(persisted, "persisted") : void 0;
879
+ const result = secret ?? {
880
+ value: generate(),
881
+ source: "generated"
882
+ };
883
+ if (!secret) {
884
+ writeSecretFile(path, result.value);
885
+ logger.info("oauth_master_secret_generated", { path });
886
+ }
887
+ if (storeDirectory(input.storeUri) === resolve(input.configDir)) logger.warn("oauth_master_secret_beside_store", { hint: "The auto-generated master secret sits next to the file store: fine locally, but a deployment should supply OAUTH_MASTER_SECRET from a secret manager instead." });
888
+ return result;
889
+ };
890
+ //#endregion
891
+ //#region src/auth/server/authorization-server.ts
892
+ const decoder = new TextDecoder();
893
+ const asVerified = (claims, issuer, resource) => {
894
+ const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
895
+ if (!(claims.iss === issuer && audiences.includes(resource) && typeof claims.exp === "number" && claims.exp * 1e3 > Date.now() && typeof claims.zd === "string" && typeof claims.gid === "string")) return void 0;
896
+ return {
897
+ zendeskAccessToken: claims.zd,
898
+ grantId: claims.gid,
899
+ clientId: String(claims.client_id),
900
+ subject: String(claims.sub),
901
+ canWrite: typeof claims.scope === "string" && claims.scope.split(" ").includes("write"),
902
+ expiresAt: claims.exp
903
+ };
904
+ };
905
+ const createAuthorizationServer = (options) => {
906
+ const { config, issuer, ring, persistent } = options;
907
+ const logger = options.logger ?? silentLogger;
908
+ const resource = `${issuer}/mcp`;
909
+ const upstream = {
910
+ subdomain: config.subdomain,
911
+ clientId: config.oauthClientId
912
+ };
913
+ const zendeskGrants = createZendeskGrants({
914
+ records: createSealedCollection(persistent, "ZendeskTokens", ring),
915
+ upstream,
916
+ logger,
917
+ refreshMarginMs: 39e5
918
+ });
919
+ const logRevokeFailure = (err) => {
920
+ logger.error("oauth_grant_revoke_failed", {
921
+ error: err instanceof Error ? err.message : String(err),
922
+ hint: "Revoked in this process only. Fix the store before restarting: its next successful write persists the removal."
923
+ });
924
+ };
925
+ const provider = buildProvider({
926
+ issuer,
927
+ resource,
928
+ ring,
929
+ adapter: createAdapterFactory({ persistent }, ring),
930
+ zendeskGrants,
931
+ allowWrite: !config.readOnly,
932
+ isAllowedOrigin: options.isAllowedOrigin,
933
+ behindProxy: config.publicUrl?.startsWith("https:") === true,
934
+ fetch: options.fetch
935
+ });
936
+ provider.on("server_error", (_ctx, err) => {
937
+ logger.error("oauth_server_error", { error: err.message });
938
+ });
939
+ const revokedGrants = createExpiringMap();
940
+ provider.on("grant.revoked", (_ctx, grantId) => {
941
+ revokedGrants.set(grantId, true, ACCESS_TOKEN_TTL_S * 1e3);
942
+ zendeskGrants.remove(grantId).catch(logRevokeFailure);
943
+ });
944
+ const interactions = createInteractionRoutes({
945
+ provider,
946
+ upstream,
947
+ issuer,
948
+ readOnly: config.readOnly,
949
+ zendeskGrants,
950
+ consentMemory: createSealedCollection(persistent, "Consent", ring),
951
+ trustedClients: trustedClientSet(config.oauthTrustedClients, config.defaultTrustedClients),
952
+ logger
953
+ });
954
+ const callback = provider.callback();
955
+ const readAccessToken = async (bearer) => {
956
+ const { kid } = decodeProtectedHeader(bearer);
957
+ const keySet = ring.find((set) => set.accessToken.kid === kid);
958
+ if (!keySet) return void 0;
959
+ const { plaintext } = await compactDecrypt(bearer, keySet.accessToken.key);
960
+ return asVerified(JSON.parse(decoder.decode(plaintext)), issuer, resource);
961
+ };
962
+ return {
963
+ issuer,
964
+ resource,
965
+ protectedResourceMetadata: {
966
+ resource,
967
+ authorization_servers: [issuer],
968
+ bearer_methods_supported: ["header"],
969
+ scopes_supported: supportedScopes(config.readOnly)
970
+ },
971
+ handle: async (req, res) => {
972
+ const url = new URL(req.url ?? "/", issuer);
973
+ if (url.pathname === "/oauth/callback" && req.method === "GET") {
974
+ interactions.upstreamCallback(res, url);
975
+ return;
976
+ }
977
+ if (url.pathname.startsWith("/interaction/")) {
978
+ await interactions.handle(req, res, url);
979
+ return;
980
+ }
981
+ await callback(req, res);
982
+ },
983
+ verifyAccessToken: async (bearer) => {
984
+ const verified = await readAccessToken(bearer).catch(() => void 0);
985
+ return verified && !revokedGrants.get(verified.grantId) ? verified : void 0;
986
+ },
987
+ revokeGrant: async (grantId) => {
988
+ revokedGrants.set(grantId, true, ACCESS_TOKEN_TTL_S * 1e3);
989
+ try {
990
+ const grant = await provider.Grant.find(grantId);
991
+ await Promise.all([
992
+ grant?.destroy(),
993
+ provider.RefreshToken.adapter.revokeByGrantId(grantId),
994
+ zendeskGrants.remove(grantId)
995
+ ]);
996
+ logger.info("oauth_grant_revoked", { reason: "zendesk_unauthorized" });
997
+ } catch (err) {
998
+ logRevokeFailure(err);
999
+ }
1000
+ }
1001
+ };
1002
+ };
1003
+ /**
1004
+ * Resolve the master secret and open the store, before the HTTP port is bound:
1005
+ * a short supplied secret or an unusable store stops startup here. Returns the
1006
+ * builder to call once the public URL (issuer) is known.
1007
+ */
1008
+ const prepareAuthorizationServer = async (config, logger = silentLogger) => {
1009
+ const dir = configDir();
1010
+ const storeUri = config.oauthStore ?? pathToFileURL(join(dir, "oauth-store.json")).href;
1011
+ const secret = resolveMasterSecret({
1012
+ value: config.oauthMasterSecret,
1013
+ file: config.oauthMasterSecretFile,
1014
+ configDir: dir,
1015
+ storeUri
1016
+ }, logger);
1017
+ const ring = deriveKeyRing(secret.value);
1018
+ const persistent = openStore(storeUri);
1019
+ return (issuer, isAllowedOrigin, overrides = {}) => createAuthorizationServer({
1020
+ config,
1021
+ issuer,
1022
+ ring,
1023
+ persistent,
1024
+ isAllowedOrigin,
1025
+ logger,
1026
+ fetch: overrides.fetch
1027
+ });
1028
+ };
1029
+ //#endregion
1030
+ //#region src/transports/http.ts
1031
+ const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
1032
+ "0.0.0.0",
1033
+ "::",
1034
+ "*"
1035
+ ]);
1036
+ const TRAILING_SLASHES = /\/+$/;
1037
+ const DEFAULT_BROWSER_MCP_CLIENT_ORIGINS = [
1038
+ "https://chatgpt.com",
1039
+ "https://chat.openai.com",
1040
+ "https://claude.ai",
1041
+ "https://gemini.google.com",
1042
+ "https://copilot.microsoft.com",
1043
+ "https://www.perplexity.ai",
1044
+ "https://chat.mistral.ai",
1045
+ "https://grok.com"
1046
+ ];
1047
+ const CORS_ALLOWED_METHODS = "GET, POST, DELETE, OPTIONS";
1048
+ const CORS_ALLOWED_HEADERS = "Authorization, Content-Type, Accept, mcp-session-id, mcp-protocol-version, last-event-id";
1049
+ const CORS_EXPOSE_HEADERS = "mcp-session-id";
1050
+ const CORS_MAX_AGE = "600";
1051
+ const LOCALHOST_HOSTNAMES = /* @__PURE__ */ new Set([
1052
+ "localhost",
1053
+ "127.0.0.1",
1054
+ "[::1]"
1055
+ ]);
1056
+ const ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
1057
+ /**
1058
+ * Returns the origin string to reflect in `Access-Control-Allow-Origin`, or
1059
+ * `undefined` if the origin is not allowed.
1060
+ *
1061
+ * The returned value is **never the raw `Origin` request header**. It comes
1062
+ * from one of three sanitization points:
1063
+ *
1064
+ * 1. An entry of the hardcoded `DEFAULT_BROWSER_MCP_CLIENT_ORIGINS` array.
1065
+ * 2. An entry of the operator-configured `extraOrigins` array.
1066
+ * 3. A loopback origin rebuilt from validated URL components after the
1067
+ * hostname has been allowlisted against `LOCALHOST_HOSTNAMES`.
1068
+ *
1069
+ * This shape keeps the dataflow from request header to response header
1070
+ * gated by a constant allowlist, which is the pattern CodeQL's
1071
+ * `js/cors-misconfiguration-for-credentials` rule recognises as safe when
1072
+ * combined with `Access-Control-Allow-Credentials: true`.
1073
+ */
1074
+ const resolveAllowedOrigin = (origin, extraOrigins) => {
1075
+ const defaultMatch = DEFAULT_BROWSER_MCP_CLIENT_ORIGINS.find((entry) => entry === origin);
1076
+ if (defaultMatch) return defaultMatch;
1077
+ const extraMatch = extraOrigins?.find((entry) => entry === origin);
1078
+ if (extraMatch) return extraMatch;
1079
+ try {
1080
+ const url = new URL(origin);
1081
+ if (!ALLOWED_PROTOCOLS.has(url.protocol)) return void 0;
1082
+ if (!LOCALHOST_HOSTNAMES.has(url.hostname)) return void 0;
1083
+ const port = url.port || (url.protocol === "https:" ? "443" : "80");
1084
+ return `${url.protocol}//${url.hostname}:${port}`;
1085
+ } catch {
1086
+ return;
1087
+ }
1088
+ };
1089
+ const applyCorsHeaders = (req, res, extraOrigins) => {
1090
+ const requestOrigin = req.headers["origin"];
1091
+ if (typeof requestOrigin !== "string" || requestOrigin.length === 0) return;
1092
+ const allowedOrigin = resolveAllowedOrigin(requestOrigin, extraOrigins);
1093
+ if (!allowedOrigin) return;
1094
+ res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
1095
+ res.setHeader("Vary", "Origin");
1096
+ res.setHeader("Access-Control-Allow-Credentials", "true");
1097
+ res.setHeader("Access-Control-Expose-Headers", CORS_EXPOSE_HEADERS);
1098
+ };
1099
+ const handleCorsPreflight = (req, res, extraOrigins) => {
1100
+ if (req.method !== "OPTIONS") return false;
1101
+ applyCorsHeaders(req, res, extraOrigins);
1102
+ if (res.getHeader("Access-Control-Allow-Origin")) {
1103
+ res.setHeader("Access-Control-Allow-Methods", CORS_ALLOWED_METHODS);
1104
+ res.setHeader("Access-Control-Allow-Headers", CORS_ALLOWED_HEADERS);
1105
+ res.setHeader("Access-Control-Max-Age", CORS_MAX_AGE);
1106
+ }
1107
+ res.writeHead(204);
1108
+ res.end();
1109
+ return true;
1110
+ };
1111
+ const resolvePublicUrl = (config, logger = silentLogger) => {
1112
+ if (config.publicUrl) return config.publicUrl.replace(TRAILING_SLASHES, "");
1113
+ if (!WILDCARD_HOSTS.has(config.host)) return `http://${config.host}:${config.port}`;
1114
+ logger.warn("public_url_unset", {
1115
+ host: config.host,
1116
+ advertised: `http://${config.host}:${config.port}`,
1117
+ hint: "OAuth discovery will advertise a non-routable resource identifier and spec-compliant MCP clients may refuse the connection. Set PUBLIC_URL (or --public-url) to the URL clients use to reach this server (e.g. https://your-host.example.com)."
1118
+ });
1119
+ return `http://${config.host}:${config.port}`;
1120
+ };
1121
+ const MISSING_BEARER_MESSAGE = "Missing or invalid access token. Sign in through this server: its OAuth authorization server is named in the protected resource metadata.";
1122
+ const extractBearer = (request) => {
1123
+ const header = request.headers["authorization"];
1124
+ if (typeof header !== "string") return void 0;
1125
+ if (!header.toLowerCase().startsWith("bearer ")) return void 0;
1126
+ return header.slice(7).trim();
1127
+ };
1128
+ const PROTECTED_RESOURCE_PATHS = ["/.well-known/oauth-protected-resource/mcp", "/.well-known/oauth-protected-resource"];
1129
+ const sendJson = (res, status, body) => {
1130
+ res.writeHead(status, { "Content-Type": "application/json" });
1131
+ res.end(JSON.stringify(body));
1132
+ };
1133
+ const sendJsonRpcError = (res, status, code, message, headers = {}) => {
1134
+ res.writeHead(status, {
1135
+ "Content-Type": "application/json",
1136
+ ...headers
1137
+ });
1138
+ res.end(JSON.stringify({
1139
+ error: {
1140
+ code,
1141
+ message
1142
+ },
1143
+ id: null,
1144
+ jsonrpc: "2.0"
1145
+ }));
1146
+ };
1147
+ const failRequest = (res, err) => {
1148
+ const message = err instanceof Error ? err.message : "Internal Server Error";
1149
+ if (!res.headersSent) {
1150
+ sendJsonRpcError(res, 500, -32603, message);
1151
+ return;
1152
+ }
1153
+ if (!res.writableEnded) res.end();
1154
+ };
1155
+ const sendUnauthorized = (res, issuer) => {
1156
+ const wwwAuthenticate = `Bearer resource_metadata="${issuer}${PROTECTED_RESOURCE_PATHS[0]}", error="invalid_token", error_description="${MISSING_BEARER_MESSAGE}"`;
1157
+ sendJsonRpcError(res, 401, -32e3, MISSING_BEARER_MESSAGE, { "WWW-Authenticate": wwwAuthenticate });
1158
+ };
1159
+ const MAX_BODY_BYTES = 4194304;
1160
+ const readJsonBody = (req, maxBodyBytes) => new Promise((resolve) => {
1161
+ const chunks = [];
1162
+ let total = 0;
1163
+ let settled = false;
1164
+ const settle = (result) => {
1165
+ if (settled) return;
1166
+ settled = true;
1167
+ resolve(result);
1168
+ };
1169
+ req.on("data", (chunk) => {
1170
+ total += chunk.length;
1171
+ if (total > maxBodyBytes) {
1172
+ req.removeAllListeners("data");
1173
+ settle({
1174
+ ok: false,
1175
+ status: 413,
1176
+ rpcCode: -32600,
1177
+ message: `Request body exceeds ${maxBodyBytes} bytes.`
1178
+ });
1179
+ return;
1180
+ }
1181
+ chunks.push(chunk);
1182
+ });
1183
+ req.on("end", () => {
1184
+ const raw = Buffer.concat(chunks).toString("utf8");
1185
+ if (!raw) {
1186
+ settle({
1187
+ ok: true,
1188
+ value: void 0
1189
+ });
1190
+ return;
1191
+ }
1192
+ try {
1193
+ settle({
1194
+ ok: true,
1195
+ value: JSON.parse(raw)
1196
+ });
1197
+ } catch {
1198
+ settle({
1199
+ ok: false,
1200
+ status: 400,
1201
+ rpcCode: -32700,
1202
+ message: "Parse error: request body is not valid JSON."
1203
+ });
1204
+ }
1205
+ });
1206
+ req.on("error", () => settle({
1207
+ ok: false,
1208
+ status: 400,
1209
+ rpcCode: -32600,
1210
+ message: "Request body could not be read."
1211
+ }));
1212
+ });
1213
+ const respondBodyError = (req, res, failure) => {
1214
+ const headers = failure.status === 413 ? { Connection: "close" } : {};
1215
+ sendJsonRpcError(res, failure.status, failure.rpcCode, failure.message, headers);
1216
+ if (failure.status === 413) {
1217
+ if (res.writableFinished) req.destroy();
1218
+ else res.once("finish", () => req.destroy());
1219
+ }
1220
+ };
1221
+ const SESSION_IDLE_TIMEOUT_MS = 18e5;
1222
+ const SESSION_SWEEP_INTERVAL_MS = 6e4;
1223
+ const startHttpTransport = async (config, logger = silentLogger, options = {}) => {
1224
+ const buildAuthorizationServer = await prepareAuthorizationServer(config, logger);
1225
+ let authorizationServer;
1226
+ const sessions = /* @__PURE__ */ new Map();
1227
+ const idleTimeoutMs = options.sessionIdleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS;
1228
+ const maxBodyBytes = options.maxBodyBytes ?? 4194304;
1229
+ const dispatchToSession = async (req, res, sessionId, token) => {
1230
+ const session = sessions.get(sessionId);
1231
+ if (!session || session.subject !== token.subject) return false;
1232
+ if (session.canWrite && !token.canWrite) return false;
1233
+ session.auth.zendeskToken = token.zendeskAccessToken;
1234
+ session.auth.grantId = token.grantId;
1235
+ session.lastActivityAt = Date.now();
1236
+ const body = req.method === "POST" ? await readJsonBody(req, maxBodyBytes) : {
1237
+ ok: true,
1238
+ value: void 0
1239
+ };
1240
+ if (!body.ok) {
1241
+ respondBodyError(req, res, body);
1242
+ return true;
1243
+ }
1244
+ await session.transport.handleRequest(req, res, body.value);
1245
+ return true;
1246
+ };
1247
+ const openSession = async (req, res, body, token, as) => {
1248
+ const auth = {
1249
+ zendeskToken: token.zendeskAccessToken,
1250
+ grantId: token.grantId
1251
+ };
1252
+ const canWrite = !config.readOnly && token.canWrite;
1253
+ const server = createMcpServer(canWrite ? config : {
1254
+ ...config,
1255
+ readOnly: true
1256
+ }, () => auth.zendeskToken, logger, () => void as.revokeGrant(auth.grantId));
1257
+ const transport = new NodeStreamableHTTPServerTransport({
1258
+ sessionIdGenerator: () => randomUUID(),
1259
+ onsessioninitialized: (newId) => {
1260
+ sessions.set(newId, {
1261
+ transport,
1262
+ auth,
1263
+ subject: token.subject,
1264
+ canWrite,
1265
+ lastActivityAt: Date.now(),
1266
+ close: async () => {
1267
+ await transport.close();
1268
+ await server.close();
1269
+ }
1270
+ });
1271
+ }
1272
+ });
1273
+ transport.onclose = () => {
1274
+ if (transport.sessionId) sessions.delete(transport.sessionId);
1275
+ };
1276
+ await server.connect(transport);
1277
+ await transport.handleRequest(req, res, body);
1278
+ };
1279
+ const handleMcpRequest = async (req, res, as) => {
1280
+ const bearer = extractBearer(req);
1281
+ const token = bearer ? await as.verifyAccessToken(bearer) : void 0;
1282
+ if (!token) {
1283
+ sendUnauthorized(res, as.issuer);
1284
+ return;
1285
+ }
1286
+ const sessionId = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : void 0;
1287
+ if (sessionId && await dispatchToSession(req, res, sessionId, token)) return;
1288
+ if (req.method !== "POST") {
1289
+ sendJsonRpcError(res, 400, -32e3, "No active session; initialize via POST first.");
1290
+ return;
1291
+ }
1292
+ const body = await readJsonBody(req, maxBodyBytes);
1293
+ if (!body.ok) {
1294
+ respondBodyError(req, res, body);
1295
+ return;
1296
+ }
1297
+ await openSession(req, res, body.value, token, as);
1298
+ };
1299
+ const route = async (req, res, as) => {
1300
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1301
+ if (req.method === "GET" && PROTECTED_RESOURCE_PATHS.includes(url.pathname)) {
1302
+ sendJson(res, 200, as.protectedResourceMetadata);
1303
+ return;
1304
+ }
1305
+ if (req.method === "GET" && url.pathname === "/healthz") {
1306
+ sendJson(res, 200, {
1307
+ status: "ok",
1308
+ subdomain: config.subdomain
1309
+ });
1310
+ return;
1311
+ }
1312
+ if (url.pathname === "/mcp") {
1313
+ await handleMcpRequest(req, res, as);
1314
+ return;
1315
+ }
1316
+ await as.handle(req, res);
1317
+ };
1318
+ const requestListener = async (req, res) => {
1319
+ try {
1320
+ if (handleCorsPreflight(req, res, config.corsOrigins)) return;
1321
+ applyCorsHeaders(req, res, config.corsOrigins);
1322
+ if (!authorizationServer) {
1323
+ sendJsonRpcError(res, 503, -32e3, "Server is starting.");
1324
+ return;
1325
+ }
1326
+ await route(req, res, authorizationServer);
1327
+ } catch (err) {
1328
+ failRequest(res, err);
1329
+ }
1330
+ };
1331
+ const httpServer = createServer((req, res) => {
1332
+ requestListener(req, res);
1333
+ });
1334
+ await new Promise((resolve, reject) => {
1335
+ httpServer.once("error", reject);
1336
+ httpServer.listen(config.port, config.host, () => {
1337
+ httpServer.off("error", reject);
1338
+ resolve();
1339
+ });
1340
+ });
1341
+ const addr = httpServer.address();
1342
+ const boundPort = typeof addr === "object" && addr !== null ? addr.port : config.port;
1343
+ authorizationServer = buildAuthorizationServer(resolvePublicUrl({
1344
+ ...config,
1345
+ port: boundPort
1346
+ }, logger), (origin) => resolveAllowedOrigin(origin, config.corsOrigins) !== void 0, { fetch: options.cimdFetch });
1347
+ logger.info("http_transport_ready", {
1348
+ host: config.host,
1349
+ port: boundPort,
1350
+ issuer: authorizationServer.issuer
1351
+ });
1352
+ const sweepIdleSessions = async () => {
1353
+ const cutoff = Date.now() - idleTimeoutMs;
1354
+ for (const [id, session] of sessions) {
1355
+ if (session.lastActivityAt > cutoff) continue;
1356
+ sessions.delete(id);
1357
+ try {
1358
+ await session.close();
1359
+ } catch (err) {
1360
+ logger.warn("session_close_failed", {
1361
+ sessionId: id,
1362
+ error: err instanceof Error ? err.message : String(err)
1363
+ });
1364
+ }
1365
+ }
1366
+ };
1367
+ const sweeper = setInterval(() => void sweepIdleSessions(), options.sweepIntervalMs ?? SESSION_SWEEP_INTERVAL_MS);
1368
+ sweeper.unref();
1369
+ return {
1370
+ port: boundPort,
1371
+ close: async () => {
1372
+ clearInterval(sweeper);
1373
+ await Promise.all([...sessions.values()].map((session) => session.close()));
1374
+ sessions.clear();
1375
+ httpServer.closeAllConnections();
1376
+ await new Promise((resolve, reject) => {
1377
+ httpServer.close((err) => err ? reject(err) : resolve());
1378
+ });
1379
+ }
1380
+ };
1381
+ };
1382
+ //#endregion
1383
+ export { DEFAULT_BROWSER_MCP_CLIENT_ORIGINS, MAX_BODY_BYTES, extractBearer, resolveAllowedOrigin, resolvePublicUrl, startHttpTransport };