@jini-ai/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/agent-install/index.d.ts +9 -0
- package/dist/agent-install/index.d.ts.map +1 -0
- package/dist/agent-install/index.js +9 -0
- package/dist/agent-install/index.js.map +1 -0
- package/dist/agent-install/install.d.ts +97 -0
- package/dist/agent-install/install.d.ts.map +1 -0
- package/dist/agent-install/install.js +410 -0
- package/dist/agent-install/install.js.map +1 -0
- package/dist/bin/serve.d.ts +75 -0
- package/dist/bin/serve.d.ts.map +1 -0
- package/dist/bin/serve.js +79 -0
- package/dist/bin/serve.js.map +1 -0
- package/dist/client/client.d.ts +48 -0
- package/dist/client/client.d.ts.map +1 -0
- package/dist/client/client.js +242 -0
- package/dist/client/client.js.map +1 -0
- package/dist/client/index.d.ts +8 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +8 -0
- package/dist/client/index.js.map +1 -0
- package/dist/core/config.d.ts +157 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +475 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +14 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/install-info.d.ts +69 -0
- package/dist/core/install-info.d.ts.map +1 -0
- package/dist/core/install-info.js +79 -0
- package/dist/core/install-info.js.map +1 -0
- package/dist/core/oauth.d.ts +274 -0
- package/dist/core/oauth.d.ts.map +1 -0
- package/dist/core/oauth.js +667 -0
- package/dist/core/oauth.js.map +1 -0
- package/dist/core/secure-write.d.ts +17 -0
- package/dist/core/secure-write.d.ts.map +1 -0
- package/dist/core/secure-write.js +72 -0
- package/dist/core/secure-write.js.map +1 -0
- package/dist/core/tokens.d.ts +72 -0
- package/dist/core/tokens.d.ts.map +1 -0
- package/dist/core/tokens.js +197 -0
- package/dist/core/tokens.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/server/daemon-client.d.ts +21 -0
- package/dist/server/daemon-client.d.ts.map +1 -0
- package/dist/server/daemon-client.js +176 -0
- package/dist/server/daemon-client.js.map +1 -0
- package/dist/server/index.d.ts +18 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +18 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/resource-protocol.d.ts +38 -0
- package/dist/server/resource-protocol.d.ts.map +1 -0
- package/dist/server/resource-protocol.js +68 -0
- package/dist/server/resource-protocol.js.map +1 -0
- package/dist/server/resources/active-resource.d.ts +12 -0
- package/dist/server/resources/active-resource.d.ts.map +1 -0
- package/dist/server/resources/active-resource.js +46 -0
- package/dist/server/resources/active-resource.js.map +1 -0
- package/dist/server/tool-protocol.d.ts +45 -0
- package/dist/server/tool-protocol.d.ts.map +1 -0
- package/dist/server/tool-protocol.js +74 -0
- package/dist/server/tool-protocol.js.map +1 -0
- package/dist/server/tool-server.d.ts +105 -0
- package/dist/server/tool-server.d.ts.map +1 -0
- package/dist/server/tool-server.js +96 -0
- package/dist/server/tool-server.js.map +1 -0
- package/dist/server/tools/delegated-tool.d.ts +15 -0
- package/dist/server/tools/delegated-tool.d.ts.map +1 -0
- package/dist/server/tools/delegated-tool.js +90 -0
- package/dist/server/tools/delegated-tool.js.map +1 -0
- package/dist/server/tools/run-tools.d.ts +14 -0
- package/dist/server/tools/run-tools.d.ts.map +1 -0
- package/dist/server/tools/run-tools.js +142 -0
- package/dist/server/tools/run-tools.js.map +1 -0
- package/dist/server/tools/tool-catalog-tools.d.ts +8 -0
- package/dist/server/tools/tool-catalog-tools.d.ts.map +1 -0
- package/dist/server/tools/tool-catalog-tools.js +66 -0
- package/dist/server/tools/tool-catalog-tools.js.map +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @jini-ai/mcp/core/oauth
|
|
3
|
+
* Daemon-side OAuth 2.1 / PKCE client for remote (HTTP / SSE) MCP servers:
|
|
4
|
+
* auth-server discovery, dynamic client registration, the authorize/token/refresh
|
|
5
|
+
* exchanges, and the in-memory `PendingAuthCache`. Part of the MCP `core` kernel;
|
|
6
|
+
* depends on no sibling subdirectory.
|
|
7
|
+
*/
|
|
8
|
+
// Daemon-side OAuth 2.1 client for HTTP / SSE MCP servers.
|
|
9
|
+
//
|
|
10
|
+
// Replaces the per-agent `mcp-remote` subprocess that bound a transient
|
|
11
|
+
// `localhost:<port>` listener — that pattern can never work for a cloud-
|
|
12
|
+
// deployed daemon (the user's browser can't reach the listener) and it
|
|
13
|
+
// also broke locally because the listener died with the agent turn.
|
|
14
|
+
//
|
|
15
|
+
// What this module owns:
|
|
16
|
+
// - Discovery of the auth server for a given MCP URL
|
|
17
|
+
// (RFC 9728 protected-resource → RFC 8414 authorization-server).
|
|
18
|
+
// - Dynamic Client Registration (RFC 7591) when the server supports it,
|
|
19
|
+
// cached per `(authServerUrl, redirectUri)` in `<dataDir>/mcp-oauth-clients.json`
|
|
20
|
+
// so we register once and reuse forever.
|
|
21
|
+
// - PKCE (RFC 7636) code-verifier / code-challenge generation.
|
|
22
|
+
// - Authorization-code → token exchange and refresh-token rotation.
|
|
23
|
+
// - In-memory state cache keyed by the `state` parameter, used to look
|
|
24
|
+
// up the originating server + verifier when the browser hits our
|
|
25
|
+
// callback endpoint.
|
|
26
|
+
//
|
|
27
|
+
// Token persistence lives in `tokens.ts`. This file is the protocol
|
|
28
|
+
// layer; storage is somebody else's job.
|
|
29
|
+
import { readFile } from 'node:fs/promises';
|
|
30
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { Agent } from 'undici';
|
|
33
|
+
import { assertSafePublicUrl, createValidatingLookup } from '@jini-ai/platform';
|
|
34
|
+
import { writeSecretFileAtomic } from './secure-write.js';
|
|
35
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Outbound-fetch safety (SEC-RB-001 / CR-005).
|
|
37
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
38
|
+
//
|
|
39
|
+
// Every network hop below — protected-resource discovery, authorization-
|
|
40
|
+
// server discovery, dynamic client registration, and the token endpoint —
|
|
41
|
+
// fetches a URL that is ultimately caller- or metadata-controlled (a
|
|
42
|
+
// user-supplied MCP server URL, or an endpoint a remote server's own
|
|
43
|
+
// discovery document names). A hostile or compromised server can point any
|
|
44
|
+
// of these at an internal service, a cloud-metadata endpoint, or an
|
|
45
|
+
// attacker-controlled host that redirects or replies with an oversized /
|
|
46
|
+
// slow response. `safeOAuthFetch` is the single choke point all of that
|
|
47
|
+
// traffic goes through:
|
|
48
|
+
// - HTTPS-only (no plaintext downgrade).
|
|
49
|
+
// - `@jini-ai/platform`'s `assertSafePublicUrl` rejects embedded credentials,
|
|
50
|
+
// localhost, and literal private/link-local IPs before any socket opens.
|
|
51
|
+
// - A `createValidatingLookup`-wrapped dispatcher re-validates the
|
|
52
|
+
// *actual* resolved address at connection time, closing the
|
|
53
|
+
// DNS-rebinding / TOCTOU gap a one-time pre-check would leave open —
|
|
54
|
+
// mirrors `packages/deploy/src/reachability.ts` and
|
|
55
|
+
// `packages/platform/src/asset-cache.ts`, the two existing SSRF-safe
|
|
56
|
+
// fetch call sites in this repo.
|
|
57
|
+
// - Redirects are refused (`redirect: 'error'` plus an explicit
|
|
58
|
+
// status/type check, so a test double that bypasses real fetch
|
|
59
|
+
// semantics is still caught) rather than followed.
|
|
60
|
+
// - Every response is read through a byte-capped reader and every
|
|
61
|
+
// request carries an `AbortSignal`-based timeout.
|
|
62
|
+
const OAUTH_FETCH_TIMEOUT_MS = 10_000;
|
|
63
|
+
const OAUTH_FETCH_MAX_BYTES = 1_000_000; // 1 MB — generous for OAuth metadata/token JSON
|
|
64
|
+
function assertSafeOAuthUrl(raw) {
|
|
65
|
+
const url = assertSafePublicUrl(raw);
|
|
66
|
+
if (url.protocol !== 'https:') {
|
|
67
|
+
// OAuth authorization servers always serve discovery/DCR/token endpoints
|
|
68
|
+
// over TLS; a plaintext candidate is never legitimate and downgrading a
|
|
69
|
+
// request that may carry client secrets or tokens to http is its own risk.
|
|
70
|
+
throw new Error(`OAuth endpoint must use https: ${raw}`);
|
|
71
|
+
}
|
|
72
|
+
return url;
|
|
73
|
+
}
|
|
74
|
+
// A fresh Agent per call would needlessly discard connection pooling; the
|
|
75
|
+
// common (no test override) case shares one lazily-created dispatcher. A
|
|
76
|
+
// caller-supplied `lookupImpl` (tests only) always gets its own dispatcher
|
|
77
|
+
// instead of touching the shared one — mirrors reachability.ts's own
|
|
78
|
+
// `resolveDispatcher`.
|
|
79
|
+
let defaultOAuthDispatcher = null;
|
|
80
|
+
function resolveOAuthDispatcher(lookupImpl) {
|
|
81
|
+
if (lookupImpl) {
|
|
82
|
+
return new Agent({ connect: { lookup: createValidatingLookup(lookupImpl) } });
|
|
83
|
+
}
|
|
84
|
+
defaultOAuthDispatcher ??= new Agent({ connect: { lookup: createValidatingLookup() } });
|
|
85
|
+
return defaultOAuthDispatcher;
|
|
86
|
+
}
|
|
87
|
+
function isRedirectResponse(res) {
|
|
88
|
+
return (res.status >= 300 && res.status < 400) || res.type === 'opaqueredirect';
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Read a response body up to `maxBytes`, aborting `controller` (when given)
|
|
92
|
+
* and throwing the moment the cap is exceeded rather than buffering an
|
|
93
|
+
* unbounded body first. Falls back to a single `res.text()` call (tolerant
|
|
94
|
+
* of a throw, matching the old `safeText`'s forgiving error-path behavior)
|
|
95
|
+
* for response-like test doubles that don't expose a real `ReadableStream`
|
|
96
|
+
* body.
|
|
97
|
+
*
|
|
98
|
+
* `controller` is real, intentional API surface — a caller that already has
|
|
99
|
+
* an in-flight fetch's `AbortController` can pass it so an oversized
|
|
100
|
+
* response stops being pulled from upstream the moment the cap is exceeded,
|
|
101
|
+
* not merely rejected after the fact — but no real call site in this file
|
|
102
|
+
* currently has one in scope to pass (each of the four call sites reads a
|
|
103
|
+
* `Response` it received after its own `safeOAuthFetch` call already
|
|
104
|
+
* returned, by which point that call's own `AbortController` is out of
|
|
105
|
+
* scope). Exported (not just internal) so this real, functioning behavior
|
|
106
|
+
* is directly unit-testable without inventing an unused call site just to
|
|
107
|
+
* reach it — matching this repo's established "extract into a directly-
|
|
108
|
+
* testable pure function" convention.
|
|
109
|
+
*/
|
|
110
|
+
export async function readCappedText(res, maxBytes, controller) {
|
|
111
|
+
const body = res.body;
|
|
112
|
+
if (!body || typeof body.getReader !== 'function') {
|
|
113
|
+
try {
|
|
114
|
+
return await res.text();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return '';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const reader = body.getReader();
|
|
121
|
+
const chunks = [];
|
|
122
|
+
let total = 0;
|
|
123
|
+
try {
|
|
124
|
+
for (;;) {
|
|
125
|
+
const { done, value } = await reader.read();
|
|
126
|
+
if (done)
|
|
127
|
+
break;
|
|
128
|
+
if (!value)
|
|
129
|
+
continue;
|
|
130
|
+
total += value.byteLength;
|
|
131
|
+
if (total > maxBytes) {
|
|
132
|
+
controller?.abort(); // stop pulling more bytes from upstream
|
|
133
|
+
throw new Error(`response body exceeds ${maxBytes} byte limit`);
|
|
134
|
+
}
|
|
135
|
+
chunks.push(value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
try {
|
|
140
|
+
await reader.cancel();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// reader already closed/errored — nothing to release
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
147
|
+
}
|
|
148
|
+
/** Best-effort, size-capped extraction of an error response's body text for
|
|
149
|
+
* inclusion in a thrown error message. Never throws. */
|
|
150
|
+
async function safeErrorText(res) {
|
|
151
|
+
try {
|
|
152
|
+
const text = await readCappedText(res, OAUTH_FETCH_MAX_BYTES);
|
|
153
|
+
return text.slice(0, 500);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return '';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The single outbound-fetch choke point for this file. Validates the URL,
|
|
161
|
+
* attaches the connection-time SSRF guard, refuses redirects, and enforces a
|
|
162
|
+
* request timeout. Callers still own reading/bounding the response body via
|
|
163
|
+
* `readCappedText`.
|
|
164
|
+
*/
|
|
165
|
+
async function safeOAuthFetch(rawUrl, init, fetchImpl, lookupImpl) {
|
|
166
|
+
const url = assertSafeOAuthUrl(rawUrl);
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const timer = setTimeout(() => controller.abort(), OAUTH_FETCH_TIMEOUT_MS);
|
|
169
|
+
try {
|
|
170
|
+
const reqInit = {
|
|
171
|
+
method: init.method ?? 'GET',
|
|
172
|
+
headers: init.headers,
|
|
173
|
+
...(init.body !== undefined ? { body: init.body } : {}),
|
|
174
|
+
redirect: 'error',
|
|
175
|
+
signal: controller.signal,
|
|
176
|
+
};
|
|
177
|
+
// `dispatcher` is an undici extension of RequestInit; attach it at
|
|
178
|
+
// runtime (see asset-cache.ts / reachability.ts for the same pattern) to
|
|
179
|
+
// avoid the undici-types (bundled with @types/node) vs undici@7
|
|
180
|
+
// Dispatcher version skew a typed field would trip over.
|
|
181
|
+
reqInit.dispatcher = resolveOAuthDispatcher(lookupImpl);
|
|
182
|
+
let res;
|
|
183
|
+
try {
|
|
184
|
+
res = await fetchImpl(url.toString(), reqInit);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
throw new Error(`request to ${url.origin} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
188
|
+
}
|
|
189
|
+
if (isRedirectResponse(res)) {
|
|
190
|
+
// Never follow a redirect: the destination has not been validated,
|
|
191
|
+
// and a hostile or rebound server could point it at an internal
|
|
192
|
+
// address. `redirect: 'error'` already makes a *real* fetch reject
|
|
193
|
+
// before returning a Response; this explicit check is defense in
|
|
194
|
+
// depth for response-like test doubles that don't implement real
|
|
195
|
+
// HTTP redirect semantics.
|
|
196
|
+
throw new Error(`refusing to follow a redirect response from ${url.origin}`);
|
|
197
|
+
}
|
|
198
|
+
return res;
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
205
|
+
// PKCE + state helpers.
|
|
206
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
207
|
+
const VERIFIER_LEN = 64; // RFC 7636 §4.1: 43–128 chars
|
|
208
|
+
function base64url(buf) {
|
|
209
|
+
return buf
|
|
210
|
+
.toString('base64')
|
|
211
|
+
.replace(/\+/g, '-')
|
|
212
|
+
.replace(/\//g, '_')
|
|
213
|
+
.replace(/=+$/g, '');
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Generate a cryptographically random PKCE code verifier (RFC 7636 §4.1).
|
|
217
|
+
* Produces a 64-byte base64url-encoded string, within the 43–128 character range.
|
|
218
|
+
* @returns A fresh code verifier string for use in a single authorization request.
|
|
219
|
+
*/
|
|
220
|
+
export function generateCodeVerifier() {
|
|
221
|
+
return base64url(randomBytes(VERIFIER_LEN));
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Derive the S256 PKCE code challenge from a code verifier (RFC 7636 §4.2).
|
|
225
|
+
* Computes `BASE64URL(SHA256(ASCII(verifier)))`.
|
|
226
|
+
* @param verifier The code verifier string produced by `generateCodeVerifier`.
|
|
227
|
+
* @returns The base64url-encoded SHA-256 hash to pass as `code_challenge`.
|
|
228
|
+
*/
|
|
229
|
+
export function deriveCodeChallenge(verifier) {
|
|
230
|
+
return base64url(createHash('sha256').update(verifier).digest());
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Generate a cryptographically random OAuth `state` parameter.
|
|
234
|
+
* Used as the CSRF token for the authorization request; must be unique per flow.
|
|
235
|
+
* @returns A base64url-encoded 32-byte random string.
|
|
236
|
+
*/
|
|
237
|
+
export function generateState() {
|
|
238
|
+
return base64url(randomBytes(32));
|
|
239
|
+
}
|
|
240
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
241
|
+
// Discovery.
|
|
242
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
243
|
+
/**
|
|
244
|
+
* Try to fetch the protected-resource metadata for a given MCP URL.
|
|
245
|
+
*
|
|
246
|
+
* Per RFC 9728, the well-known is at the resource origin's
|
|
247
|
+
* `/.well-known/oauth-protected-resource[<path>]`. We try both the
|
|
248
|
+
* path-suffixed form and the bare `/.well-known/...` so servers that
|
|
249
|
+
* only publish at the root still work.
|
|
250
|
+
*
|
|
251
|
+
* @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
|
|
252
|
+
* guard (tests only — see `safeOAuthFetch`).
|
|
253
|
+
*/
|
|
254
|
+
export async function discoverProtectedResource(resourceUrl, fetchImpl = fetch, lookupImpl) {
|
|
255
|
+
let parsed;
|
|
256
|
+
try {
|
|
257
|
+
parsed = new URL(resourceUrl);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
const candidates = [
|
|
263
|
+
new URL(`/.well-known/oauth-protected-resource${parsed.pathname.replace(/\/+$/u, '')}`, `${parsed.protocol}//${parsed.host}`).toString(),
|
|
264
|
+
new URL('/.well-known/oauth-protected-resource', `${parsed.protocol}//${parsed.host}`).toString(),
|
|
265
|
+
];
|
|
266
|
+
for (const url of candidates) {
|
|
267
|
+
const json = await fetchJson(url, fetchImpl, lookupImpl);
|
|
268
|
+
if (json)
|
|
269
|
+
return json;
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Fetch the authorization-server metadata for an issuer URL. Tries both
|
|
275
|
+
* the OAuth (RFC 8414) and OIDC layouts (`/.well-known/oauth-authorization-server`
|
|
276
|
+
* and `/.well-known/openid-configuration`); some providers only publish one.
|
|
277
|
+
*
|
|
278
|
+
* Per RFC 8414 §3.3, a discovery document's `issuer` — when present — MUST
|
|
279
|
+
* match the issuer used to construct the request; a mismatched document is
|
|
280
|
+
* rejected rather than trusted (SEC-RB-001 / CR-005). Every endpoint the
|
|
281
|
+
* document names (`authorization_endpoint`, `token_endpoint`,
|
|
282
|
+
* `registration_endpoint`) is additionally required to be an absolute
|
|
283
|
+
* `https:` URL sharing the queried issuer's own origin, so a compromised or
|
|
284
|
+
* hostile document can't redirect DCR or token exchange at an unrelated
|
|
285
|
+
* origin.
|
|
286
|
+
*
|
|
287
|
+
* @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
|
|
288
|
+
* guard (tests only — see `safeOAuthFetch`).
|
|
289
|
+
*/
|
|
290
|
+
export async function discoverAuthServer(issuer, fetchImpl = fetch, lookupImpl) {
|
|
291
|
+
let parsedIssuer;
|
|
292
|
+
try {
|
|
293
|
+
parsedIssuer = new URL(issuer);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
const trimmed = parsedIssuer.pathname.replace(/\/+$/u, '');
|
|
299
|
+
const base = `${parsedIssuer.protocol}//${parsedIssuer.host}`;
|
|
300
|
+
const candidates = [
|
|
301
|
+
`${base}/.well-known/oauth-authorization-server${trimmed}`,
|
|
302
|
+
`${base}/.well-known/openid-configuration${trimmed}`,
|
|
303
|
+
`${base}/.well-known/oauth-authorization-server`,
|
|
304
|
+
`${base}/.well-known/openid-configuration`,
|
|
305
|
+
];
|
|
306
|
+
for (const url of candidates) {
|
|
307
|
+
const json = await fetchJson(url, fetchImpl, lookupImpl);
|
|
308
|
+
if (json && typeof json.authorization_endpoint === 'string' && typeof json.token_endpoint === 'string') {
|
|
309
|
+
if (typeof json.issuer === 'string' && json.issuer !== issuer)
|
|
310
|
+
continue; // RFC 8414 §3.3 issuer mismatch
|
|
311
|
+
if (!endpointsShareIssuerOrigin(parsedIssuer, json))
|
|
312
|
+
continue;
|
|
313
|
+
// Spread first so the explicit issuer wins (otherwise duplicate-key
|
|
314
|
+
// assignments under exactOptionalPropertyTypes complain).
|
|
315
|
+
return { ...json, issuer: json.issuer ?? issuer };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
function endpointsShareIssuerOrigin(issuerUrl, meta) {
|
|
321
|
+
const endpoints = [meta.authorization_endpoint, meta.token_endpoint, meta.registration_endpoint].filter((v) => typeof v === 'string');
|
|
322
|
+
for (const endpoint of endpoints) {
|
|
323
|
+
let parsed;
|
|
324
|
+
try {
|
|
325
|
+
parsed = new URL(endpoint);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
if (parsed.protocol !== 'https:' || parsed.origin !== issuerUrl.origin)
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
async function fetchJson(url, fetchImpl, lookupImpl) {
|
|
336
|
+
try {
|
|
337
|
+
const res = await safeOAuthFetch(url, { headers: { accept: 'application/json' } }, fetchImpl, lookupImpl);
|
|
338
|
+
if (!res.ok)
|
|
339
|
+
return null;
|
|
340
|
+
const text = await readCappedText(res, OAUTH_FETCH_MAX_BYTES);
|
|
341
|
+
return JSON.parse(text);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function clientsFile(dataDir) {
|
|
348
|
+
return path.join(dataDir, 'mcp-oauth-clients.json');
|
|
349
|
+
}
|
|
350
|
+
async function readClientCache(dataDir) {
|
|
351
|
+
try {
|
|
352
|
+
const raw = await readFile(clientsFile(dataDir), 'utf8');
|
|
353
|
+
const parsed = JSON.parse(raw);
|
|
354
|
+
if (!parsed || !Array.isArray(parsed.clients))
|
|
355
|
+
return { clients: [] };
|
|
356
|
+
return { clients: parsed.clients.filter(isRegisteredClient) };
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
const e = err;
|
|
360
|
+
if (e.code === 'ENOENT')
|
|
361
|
+
return { clients: [] };
|
|
362
|
+
throw err;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function isRegisteredClient(v) {
|
|
366
|
+
if (!v || typeof v !== 'object')
|
|
367
|
+
return false;
|
|
368
|
+
const r = v;
|
|
369
|
+
return (typeof r.authServerIssuer === 'string' &&
|
|
370
|
+
typeof r.redirectUri === 'string' &&
|
|
371
|
+
typeof r.clientId === 'string');
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Persist the client cache to `<dataDir>/mcp-oauth-clients.json`. May
|
|
375
|
+
* contain a confidential client's `clientSecret` (CR-006 / SEC-RB-002), so
|
|
376
|
+
* the file is created with owner-only (0600) permissions from the very
|
|
377
|
+
* first byte via `writeSecretFileAtomic` rather than a post-rename chmod —
|
|
378
|
+
* see that module for why.
|
|
379
|
+
*/
|
|
380
|
+
async function writeClientCache(dataDir, next) {
|
|
381
|
+
await writeSecretFileAtomic(clientsFile(dataDir), JSON.stringify(next, null, 2));
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* POST to the auth server's `registration_endpoint` per RFC 7591. Returns
|
|
385
|
+
* a freshly issued client_id (and optional client_secret). Caller is
|
|
386
|
+
* responsible for caching the result.
|
|
387
|
+
* @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
|
|
388
|
+
* guard (tests only — see `safeOAuthFetch`).
|
|
389
|
+
*/
|
|
390
|
+
export async function registerClient(registrationEndpoint, redirectUri, fetchImpl = fetch, lookupImpl) {
|
|
391
|
+
const body = {
|
|
392
|
+
redirect_uris: [redirectUri],
|
|
393
|
+
token_endpoint_auth_method: 'none',
|
|
394
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
395
|
+
response_types: ['code'],
|
|
396
|
+
client_name: 'Jini',
|
|
397
|
+
application_type: 'web',
|
|
398
|
+
};
|
|
399
|
+
const res = await safeOAuthFetch(registrationEndpoint, {
|
|
400
|
+
method: 'POST',
|
|
401
|
+
headers: {
|
|
402
|
+
'content-type': 'application/json',
|
|
403
|
+
accept: 'application/json',
|
|
404
|
+
},
|
|
405
|
+
body: JSON.stringify(body),
|
|
406
|
+
}, fetchImpl, lookupImpl);
|
|
407
|
+
if (!res.ok) {
|
|
408
|
+
const txt = await safeErrorText(res);
|
|
409
|
+
throw new Error(`dynamic client registration failed: HTTP ${res.status} ${res.statusText} ${txt}`);
|
|
410
|
+
}
|
|
411
|
+
const json = JSON.parse(await readCappedText(res, OAUTH_FETCH_MAX_BYTES));
|
|
412
|
+
if (!json.client_id) {
|
|
413
|
+
throw new Error('dynamic client registration response missing client_id');
|
|
414
|
+
}
|
|
415
|
+
const out = { clientId: json.client_id };
|
|
416
|
+
if (json.client_secret)
|
|
417
|
+
out.clientSecret = json.client_secret;
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Cached version of `registerClient`. Looks up `(authServerIssuer, redirectUri)`
|
|
422
|
+
* in the cache file and re-uses the existing client; falls back to a fresh
|
|
423
|
+
* DCR call when nothing is cached.
|
|
424
|
+
* @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
|
|
425
|
+
* guard (tests only — see `safeOAuthFetch`).
|
|
426
|
+
*/
|
|
427
|
+
export async function getOrRegisterClient(dataDir, authServer, redirectUri, fetchImpl = fetch, lookupImpl) {
|
|
428
|
+
const cache = await readClientCache(dataDir);
|
|
429
|
+
const cached = cache.clients.find((c) => c.authServerIssuer === authServer.issuer && c.redirectUri === redirectUri);
|
|
430
|
+
if (cached)
|
|
431
|
+
return cached;
|
|
432
|
+
if (!authServer.registration_endpoint) {
|
|
433
|
+
throw new Error(`auth server ${authServer.issuer} does not advertise a registration_endpoint and no client is pre-registered`);
|
|
434
|
+
}
|
|
435
|
+
const reg = await registerClient(authServer.registration_endpoint, redirectUri, fetchImpl, lookupImpl);
|
|
436
|
+
const next = {
|
|
437
|
+
authServerIssuer: authServer.issuer,
|
|
438
|
+
redirectUri,
|
|
439
|
+
clientId: reg.clientId,
|
|
440
|
+
registeredAt: Date.now(),
|
|
441
|
+
};
|
|
442
|
+
if (reg.clientSecret)
|
|
443
|
+
next.clientSecret = reg.clientSecret;
|
|
444
|
+
cache.clients.push(next);
|
|
445
|
+
await writeClientCache(dataDir, cache);
|
|
446
|
+
return next;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Assemble the authorization endpoint URL the user's browser must be directed to.
|
|
450
|
+
* Sets PKCE parameters (`code_challenge`, `code_challenge_method=S256`), the `state`
|
|
451
|
+
* CSRF token, and optionally the RFC 8707 `resource` indicator.
|
|
452
|
+
* @param input All parameters needed to build the URL.
|
|
453
|
+
* @returns The fully-qualified authorization URL as a string.
|
|
454
|
+
*/
|
|
455
|
+
export function buildAuthorizeUrl(input) {
|
|
456
|
+
const u = new URL(input.authServer.authorization_endpoint);
|
|
457
|
+
u.searchParams.set('response_type', 'code');
|
|
458
|
+
u.searchParams.set('client_id', input.clientId);
|
|
459
|
+
u.searchParams.set('redirect_uri', input.redirectUri);
|
|
460
|
+
u.searchParams.set('state', input.state);
|
|
461
|
+
u.searchParams.set('code_challenge', input.codeChallenge);
|
|
462
|
+
u.searchParams.set('code_challenge_method', 'S256');
|
|
463
|
+
if (input.scope)
|
|
464
|
+
u.searchParams.set('scope', input.scope);
|
|
465
|
+
// RFC 8707 resource indicator — narrows the issued token to the MCP
|
|
466
|
+
// resource we actually care about. Most authoritative MCP servers
|
|
467
|
+
// require it; harmless when ignored.
|
|
468
|
+
if (input.resource)
|
|
469
|
+
u.searchParams.set('resource', input.resource);
|
|
470
|
+
return u.toString();
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Exchange an authorization code for access and refresh tokens (RFC 6749 §4.1.3).
|
|
474
|
+
* Includes the PKCE `code_verifier` and, when supplied, the RFC 8707 `resource`
|
|
475
|
+
* indicator. Throws when the token endpoint returns a non-2xx status.
|
|
476
|
+
* @param input The code-exchange parameters.
|
|
477
|
+
* @param fetchImpl Injectable fetch, defaults to the global `fetch`.
|
|
478
|
+
* @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
|
|
479
|
+
* guard (tests only — see `safeOAuthFetch`).
|
|
480
|
+
* @returns The token endpoint response containing at least an `access_token`.
|
|
481
|
+
*/
|
|
482
|
+
export async function exchangeCodeForToken(input, fetchImpl = fetch, lookupImpl) {
|
|
483
|
+
const form = new URLSearchParams();
|
|
484
|
+
form.set('grant_type', 'authorization_code');
|
|
485
|
+
form.set('code', input.code);
|
|
486
|
+
form.set('redirect_uri', input.redirectUri);
|
|
487
|
+
form.set('client_id', input.clientId);
|
|
488
|
+
form.set('code_verifier', input.codeVerifier);
|
|
489
|
+
if (input.resource)
|
|
490
|
+
form.set('resource', input.resource);
|
|
491
|
+
return tokenRequest(input.tokenEndpoint, form, input.clientSecret, fetchImpl, lookupImpl);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Exchange a refresh token for a new access token (RFC 6749 §6).
|
|
495
|
+
* Preserves scope and resource binding from the original authorization.
|
|
496
|
+
* Throws when the token endpoint returns a non-2xx status.
|
|
497
|
+
* @param input The refresh parameters.
|
|
498
|
+
* @param fetchImpl Injectable fetch, defaults to the global `fetch`.
|
|
499
|
+
* @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
|
|
500
|
+
* guard (tests only — see `safeOAuthFetch`).
|
|
501
|
+
* @returns A fresh `OAuthTokenResponse`; the server may issue a new refresh token.
|
|
502
|
+
*/
|
|
503
|
+
export async function refreshAccessToken(input, fetchImpl = fetch, lookupImpl) {
|
|
504
|
+
const form = new URLSearchParams();
|
|
505
|
+
form.set('grant_type', 'refresh_token');
|
|
506
|
+
form.set('refresh_token', input.refreshToken);
|
|
507
|
+
form.set('client_id', input.clientId);
|
|
508
|
+
if (input.scope)
|
|
509
|
+
form.set('scope', input.scope);
|
|
510
|
+
if (input.resource)
|
|
511
|
+
form.set('resource', input.resource);
|
|
512
|
+
return tokenRequest(input.tokenEndpoint, form, input.clientSecret, fetchImpl, lookupImpl);
|
|
513
|
+
}
|
|
514
|
+
async function tokenRequest(tokenEndpoint, form, clientSecret, fetchImpl, lookupImpl) {
|
|
515
|
+
const headers = {
|
|
516
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
517
|
+
accept: 'application/json',
|
|
518
|
+
};
|
|
519
|
+
if (clientSecret) {
|
|
520
|
+
// RFC 6749 §2.3.1 — confidential clients use HTTP Basic with the
|
|
521
|
+
// client_id we already put in the form. Public clients (PKCE-only)
|
|
522
|
+
// skip this branch.
|
|
523
|
+
const basic = Buffer.from(`${form.get('client_id')}:${clientSecret}`).toString('base64');
|
|
524
|
+
headers['authorization'] = `Basic ${basic}`;
|
|
525
|
+
}
|
|
526
|
+
const res = await safeOAuthFetch(tokenEndpoint, { method: 'POST', headers, body: form.toString() }, fetchImpl, lookupImpl);
|
|
527
|
+
if (!res.ok) {
|
|
528
|
+
const txt = await safeErrorText(res);
|
|
529
|
+
throw new Error(`token endpoint rejected request: HTTP ${res.status} ${res.statusText} ${txt}`);
|
|
530
|
+
}
|
|
531
|
+
const json = JSON.parse(await readCappedText(res, OAUTH_FETCH_MAX_BYTES));
|
|
532
|
+
if (!json.access_token) {
|
|
533
|
+
throw new Error('token endpoint response missing access_token');
|
|
534
|
+
}
|
|
535
|
+
return json;
|
|
536
|
+
}
|
|
537
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
538
|
+
// In-memory pending-state cache.
|
|
539
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
540
|
+
/**
|
|
541
|
+
* The OAuth dance is split across two HTTP requests on our side:
|
|
542
|
+
* 1. the "start" request — we mint state + verifier
|
|
543
|
+
* 2. the "callback" request — browser returns code + state
|
|
544
|
+
* State has to survive between (1) and (2) on the daemon. We keep it in a
|
|
545
|
+
* Map with a TTL sweeper; persistence isn't needed because the user has
|
|
546
|
+
* to complete auth in the same daemon process anyway (state is single-use).
|
|
547
|
+
*/
|
|
548
|
+
export class PendingAuthCache {
|
|
549
|
+
ttlMs;
|
|
550
|
+
store = new Map();
|
|
551
|
+
timer = null;
|
|
552
|
+
constructor(ttlMs = 10 * 60 * 1000) {
|
|
553
|
+
this.ttlMs = ttlMs;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Store a pending auth state keyed by the OAuth `state` parameter.
|
|
557
|
+
* Starts the TTL sweeper if it is not already running.
|
|
558
|
+
* @param state The random `state` string from the authorization request.
|
|
559
|
+
* @param value The associated pending auth metadata to stash.
|
|
560
|
+
*/
|
|
561
|
+
put(state, value) {
|
|
562
|
+
this.store.set(state, value);
|
|
563
|
+
this.startSweeper();
|
|
564
|
+
}
|
|
565
|
+
/** One-shot consume — any successful callback removes the state so a
|
|
566
|
+
* replay can't reuse it. */
|
|
567
|
+
consume(state) {
|
|
568
|
+
const v = this.store.get(state);
|
|
569
|
+
if (!v)
|
|
570
|
+
return null;
|
|
571
|
+
this.store.delete(state);
|
|
572
|
+
if (Date.now() - v.createdAt > this.ttlMs)
|
|
573
|
+
return null;
|
|
574
|
+
return v;
|
|
575
|
+
}
|
|
576
|
+
/** Return the number of pending auth states currently held in the cache. */
|
|
577
|
+
size() {
|
|
578
|
+
return this.store.size;
|
|
579
|
+
}
|
|
580
|
+
/** Stop the background sweeper. Used by tests; production lets the
|
|
581
|
+
* timer ride on the process lifetime. */
|
|
582
|
+
stop() {
|
|
583
|
+
if (this.timer) {
|
|
584
|
+
clearInterval(this.timer);
|
|
585
|
+
this.timer = null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
startSweeper() {
|
|
589
|
+
if (this.timer)
|
|
590
|
+
return;
|
|
591
|
+
this.timer = setInterval(() => this.sweep(), Math.min(this.ttlMs, 60_000));
|
|
592
|
+
// unref so the cache doesn't keep the event loop alive in tests
|
|
593
|
+
this.timer.unref();
|
|
594
|
+
}
|
|
595
|
+
sweep() {
|
|
596
|
+
const now = Date.now();
|
|
597
|
+
for (const [k, v] of this.store) {
|
|
598
|
+
if (now - v.createdAt > this.ttlMs)
|
|
599
|
+
this.store.delete(k);
|
|
600
|
+
}
|
|
601
|
+
if (this.store.size === 0 && this.timer) {
|
|
602
|
+
clearInterval(this.timer);
|
|
603
|
+
this.timer = null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Run the entire pre-redirect half of the OAuth dance:
|
|
609
|
+
* discovery → DCR (cached) → PKCE → authorize URL.
|
|
610
|
+
*
|
|
611
|
+
* Returns everything the caller needs to (a) push the user's browser at the
|
|
612
|
+
* correct authorize URL, and (b) finish the flow when the callback hits.
|
|
613
|
+
*/
|
|
614
|
+
export async function beginAuth(input) {
|
|
615
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
616
|
+
const lookupImpl = input.lookupImpl;
|
|
617
|
+
// Step 1: ask the MCP server who its auth server is. If the server
|
|
618
|
+
// doesn't publish protected-resource metadata, fall back to assuming
|
|
619
|
+
// the resource origin IS the auth server — most "stand-alone" MCP
|
|
620
|
+
// providers host both at the same host.
|
|
621
|
+
const prm = await discoverProtectedResource(input.serverUrl, fetchImpl, lookupImpl);
|
|
622
|
+
const issuerHint = prm?.authorization_servers?.[0];
|
|
623
|
+
const issuer = issuerHint ?? new URL(input.serverUrl).origin;
|
|
624
|
+
// Step 2: discovery on the auth server.
|
|
625
|
+
const authServer = await discoverAuthServer(issuer, fetchImpl, lookupImpl);
|
|
626
|
+
if (!authServer) {
|
|
627
|
+
throw new Error(`could not discover OAuth metadata for ${issuer}`);
|
|
628
|
+
}
|
|
629
|
+
// Step 3: ensure we have a registered client_id (DCR if missing).
|
|
630
|
+
const client = await getOrRegisterClient(input.dataDir, authServer, input.redirectUri, fetchImpl, lookupImpl);
|
|
631
|
+
// Step 4: PKCE + state.
|
|
632
|
+
const codeVerifier = generateCodeVerifier();
|
|
633
|
+
const codeChallenge = deriveCodeChallenge(codeVerifier);
|
|
634
|
+
const state = generateState();
|
|
635
|
+
const scope = input.scope ??
|
|
636
|
+
(Array.isArray(prm?.scopes_supported) && prm.scopes_supported.length > 0
|
|
637
|
+
? prm.scopes_supported.join(' ')
|
|
638
|
+
: authServer.scopes_supported?.join(' '));
|
|
639
|
+
const resource = prm?.resource ?? input.serverUrl;
|
|
640
|
+
const authUrlInput = {
|
|
641
|
+
authServer,
|
|
642
|
+
clientId: client.clientId,
|
|
643
|
+
redirectUri: input.redirectUri,
|
|
644
|
+
state,
|
|
645
|
+
codeChallenge,
|
|
646
|
+
resource,
|
|
647
|
+
};
|
|
648
|
+
if (scope)
|
|
649
|
+
authUrlInput.scope = scope;
|
|
650
|
+
const authorizeUrl = buildAuthorizeUrl(authUrlInput);
|
|
651
|
+
const pending = {
|
|
652
|
+
serverId: input.serverId,
|
|
653
|
+
authServerIssuer: authServer.issuer,
|
|
654
|
+
tokenEndpoint: authServer.token_endpoint,
|
|
655
|
+
clientId: client.clientId,
|
|
656
|
+
redirectUri: input.redirectUri,
|
|
657
|
+
codeVerifier,
|
|
658
|
+
resourceUrl: resource,
|
|
659
|
+
createdAt: Date.now(),
|
|
660
|
+
};
|
|
661
|
+
if (client.clientSecret)
|
|
662
|
+
pending.clientSecret = client.clientSecret;
|
|
663
|
+
if (scope)
|
|
664
|
+
pending.scope = scope;
|
|
665
|
+
return { authorizeUrl, state, pending };
|
|
666
|
+
}
|
|
667
|
+
//# sourceMappingURL=oauth.js.map
|