@jini-ai/mcp 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/dist/server/tools/delegated-tool.js +4 -1
- package/package.json +3 -3
- package/dist/core/oauth.d.ts +0 -274
- package/dist/core/oauth.js +0 -667
- package/dist/core/secure-write.d.ts +0 -17
- package/dist/core/secure-write.js +0 -72
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# @jini-ai/mcp
|
|
2
2
|
|
|
3
|
+
## 0.4.1 — 2026-10-06
|
|
4
|
+
|
|
5
|
+
- The delegated tool gateways send input `{}` when the model omits it or sends `null`, so a no-input call no longer fails before a retry.
|
|
6
|
+
- Internal `@jini-ai/*` runtime dependencies are caret ranges (`workspace:^`) instead of exact pins.
|
|
7
|
+
|
|
3
8
|
## 0.4.0 — 2026-10-02
|
|
4
9
|
|
|
5
10
|
### BREAKING
|
|
@@ -149,7 +149,10 @@ function createDelegatedGateway(variant, { runId }, options) {
|
|
|
149
149
|
runId,
|
|
150
150
|
toolUseId: generateToolUseId({}),
|
|
151
151
|
toolId: toolId,
|
|
152
|
-
input
|
|
152
|
+
// The schema tells the model to omit `input` for a tool that takes none, but most tool
|
|
153
|
+
// handlers refuse an absent input ("input must be an object"). Sending `{}` makes the
|
|
154
|
+
// schema's promise true instead of costing a red failed call and a retry.
|
|
155
|
+
input: args.input ?? {},
|
|
153
156
|
...(variant.requireReadOnly === undefined ? {} : { requireReadOnly: variant.requireReadOnly }),
|
|
154
157
|
};
|
|
155
158
|
const data = await postDaemonJson({ baseUrl: ctx.baseUrl, route: '/api/delegated-tool-calls', body: body }, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Model Context Protocol adapters: server config schema and IO, an MCP client, and agent-side MCP server installation.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -85,8 +85,8 @@
|
|
|
85
85
|
"dependencies": {
|
|
86
86
|
"@cfworker/json-schema": "^4.1.1",
|
|
87
87
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
88
|
-
"@jini-ai/
|
|
89
|
-
"@jini-ai/
|
|
88
|
+
"@jini-ai/platform": "^0.5.1",
|
|
89
|
+
"@jini-ai/core": "^0.4.1"
|
|
90
90
|
},
|
|
91
91
|
"scripts": {
|
|
92
92
|
"build": "tsc -p tsconfig.json && chmod +x dist/bin/serve.js",
|
package/dist/core/oauth.d.ts
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
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
|
-
import { createValidatingLookup } from '@jini-ai/platform';
|
|
9
|
-
/** RFC 9728 `oauth-protected-resource` document fields we use. */
|
|
10
|
-
export interface ProtectedResourceMetadata {
|
|
11
|
-
resource?: string;
|
|
12
|
-
authorization_servers?: string[];
|
|
13
|
-
scopes_supported?: string[];
|
|
14
|
-
}
|
|
15
|
-
/** RFC 8414 / OIDC discovery document fields we use. */
|
|
16
|
-
export interface AuthorizationServerMetadata {
|
|
17
|
-
issuer: string;
|
|
18
|
-
authorization_endpoint: string;
|
|
19
|
-
token_endpoint: string;
|
|
20
|
-
registration_endpoint?: string;
|
|
21
|
-
scopes_supported?: string[];
|
|
22
|
-
response_types_supported?: string[];
|
|
23
|
-
grant_types_supported?: string[];
|
|
24
|
-
code_challenge_methods_supported?: string[];
|
|
25
|
-
token_endpoint_auth_methods_supported?: string[];
|
|
26
|
-
}
|
|
27
|
-
/** Cached client registration for a given auth server + redirect URI. */
|
|
28
|
-
export interface RegisteredClient {
|
|
29
|
-
authServerIssuer: string;
|
|
30
|
-
redirectUri: string;
|
|
31
|
-
clientId: string;
|
|
32
|
-
clientSecret?: string;
|
|
33
|
-
registeredAt: number;
|
|
34
|
-
}
|
|
35
|
-
/** RFC 6749 §5.1 token endpoint response (subset). */
|
|
36
|
-
export interface OAuthTokenResponse {
|
|
37
|
-
access_token: string;
|
|
38
|
-
token_type?: string;
|
|
39
|
-
expires_in?: number;
|
|
40
|
-
refresh_token?: string;
|
|
41
|
-
scope?: string;
|
|
42
|
-
}
|
|
43
|
-
/** In-flight authorization request. Stashed in memory while the user
|
|
44
|
-
* approves in their browser. */
|
|
45
|
-
export interface PendingAuthState {
|
|
46
|
-
serverId: string;
|
|
47
|
-
authServerIssuer: string;
|
|
48
|
-
tokenEndpoint: string;
|
|
49
|
-
clientId: string;
|
|
50
|
-
clientSecret?: string;
|
|
51
|
-
redirectUri: string;
|
|
52
|
-
codeVerifier: string;
|
|
53
|
-
scope?: string;
|
|
54
|
-
resourceUrl?: string;
|
|
55
|
-
createdAt: number;
|
|
56
|
-
}
|
|
57
|
-
type DnsLookupCb = Parameters<typeof createValidatingLookup>[0];
|
|
58
|
-
/**
|
|
59
|
-
* Read a response body up to `maxBytes`, aborting `controller` (when given)
|
|
60
|
-
* and throwing the moment the cap is exceeded rather than buffering an
|
|
61
|
-
* unbounded body first. Falls back to a single `res.text()` call (tolerant
|
|
62
|
-
* of a throw, matching the old `safeText`'s forgiving error-path behavior)
|
|
63
|
-
* for response-like test doubles that don't expose a real `ReadableStream`
|
|
64
|
-
* body.
|
|
65
|
-
*
|
|
66
|
-
* `controller` is real, intentional API surface — a caller that already has
|
|
67
|
-
* an in-flight fetch's `AbortController` can pass it so an oversized
|
|
68
|
-
* response stops being pulled from upstream the moment the cap is exceeded,
|
|
69
|
-
* not merely rejected after the fact — but no real call site in this file
|
|
70
|
-
* currently has one in scope to pass (each of the four call sites reads a
|
|
71
|
-
* `Response` it received after its own `safeOAuthFetch` call already
|
|
72
|
-
* returned, by which point that call's own `AbortController` is out of
|
|
73
|
-
* scope). Exported (not just internal) so this real, functioning behavior
|
|
74
|
-
* is directly unit-testable without inventing an unused call site just to
|
|
75
|
-
* reach it — matching this repo's established "extract into a directly-
|
|
76
|
-
* testable pure function" convention.
|
|
77
|
-
*/
|
|
78
|
-
export declare function readCappedText(res: Response, maxBytes: number, controller?: AbortController): Promise<string>;
|
|
79
|
-
/**
|
|
80
|
-
* Generate a cryptographically random PKCE code verifier (RFC 7636 §4.1).
|
|
81
|
-
* Produces a 64-byte base64url-encoded string, within the 43–128 character range.
|
|
82
|
-
* @returns A fresh code verifier string for use in a single authorization request.
|
|
83
|
-
*/
|
|
84
|
-
export declare function generateCodeVerifier(): string;
|
|
85
|
-
/**
|
|
86
|
-
* Derive the S256 PKCE code challenge from a code verifier (RFC 7636 §4.2).
|
|
87
|
-
* Computes `BASE64URL(SHA256(ASCII(verifier)))`.
|
|
88
|
-
* @param verifier The code verifier string produced by `generateCodeVerifier`.
|
|
89
|
-
* @returns The base64url-encoded SHA-256 hash to pass as `code_challenge`.
|
|
90
|
-
*/
|
|
91
|
-
export declare function deriveCodeChallenge(verifier: string): string;
|
|
92
|
-
/**
|
|
93
|
-
* Generate a cryptographically random OAuth `state` parameter.
|
|
94
|
-
* Used as the CSRF token for the authorization request; must be unique per flow.
|
|
95
|
-
* @returns A base64url-encoded 32-byte random string.
|
|
96
|
-
*/
|
|
97
|
-
export declare function generateState(): string;
|
|
98
|
-
/**
|
|
99
|
-
* Try to fetch the protected-resource metadata for a given MCP URL.
|
|
100
|
-
*
|
|
101
|
-
* Per RFC 9728, the well-known is at the resource origin's
|
|
102
|
-
* `/.well-known/oauth-protected-resource[<path>]`. We try both the
|
|
103
|
-
* path-suffixed form and the bare `/.well-known/...` so servers that
|
|
104
|
-
* only publish at the root still work.
|
|
105
|
-
*
|
|
106
|
-
* @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
|
|
107
|
-
* guard (tests only — see `safeOAuthFetch`).
|
|
108
|
-
*/
|
|
109
|
-
export declare function discoverProtectedResource(resourceUrl: string, fetchImpl?: typeof fetch, lookupImpl?: DnsLookupCb): Promise<ProtectedResourceMetadata | null>;
|
|
110
|
-
/**
|
|
111
|
-
* Fetch the authorization-server metadata for an issuer URL. Tries both
|
|
112
|
-
* the OAuth (RFC 8414) and OIDC layouts (`/.well-known/oauth-authorization-server`
|
|
113
|
-
* and `/.well-known/openid-configuration`); some providers only publish one.
|
|
114
|
-
*
|
|
115
|
-
* Per RFC 8414 §3.3, a discovery document's `issuer` — when present — MUST
|
|
116
|
-
* match the issuer used to construct the request; a mismatched document is
|
|
117
|
-
* rejected rather than trusted (SEC-RB-001 / CR-005). Every endpoint the
|
|
118
|
-
* document names (`authorization_endpoint`, `token_endpoint`,
|
|
119
|
-
* `registration_endpoint`) is additionally required to be an absolute
|
|
120
|
-
* `https:` URL sharing the queried issuer's own origin, so a compromised or
|
|
121
|
-
* hostile document can't redirect DCR or token exchange at an unrelated
|
|
122
|
-
* origin.
|
|
123
|
-
*
|
|
124
|
-
* @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
|
|
125
|
-
* guard (tests only — see `safeOAuthFetch`).
|
|
126
|
-
*/
|
|
127
|
-
export declare function discoverAuthServer(issuer: string, fetchImpl?: typeof fetch, lookupImpl?: DnsLookupCb): Promise<AuthorizationServerMetadata | null>;
|
|
128
|
-
/**
|
|
129
|
-
* POST to the auth server's `registration_endpoint` per RFC 7591. Returns
|
|
130
|
-
* a freshly issued client_id (and optional client_secret). Caller is
|
|
131
|
-
* responsible for caching the result.
|
|
132
|
-
* @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
|
|
133
|
-
* guard (tests only — see `safeOAuthFetch`).
|
|
134
|
-
*/
|
|
135
|
-
export declare function registerClient(registrationEndpoint: string, redirectUri: string, fetchImpl?: typeof fetch, lookupImpl?: DnsLookupCb): Promise<{
|
|
136
|
-
clientId: string;
|
|
137
|
-
clientSecret?: string;
|
|
138
|
-
}>;
|
|
139
|
-
/**
|
|
140
|
-
* Cached version of `registerClient`. Looks up `(authServerIssuer, redirectUri)`
|
|
141
|
-
* in the cache file and re-uses the existing client; falls back to a fresh
|
|
142
|
-
* DCR call when nothing is cached.
|
|
143
|
-
* @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
|
|
144
|
-
* guard (tests only — see `safeOAuthFetch`).
|
|
145
|
-
*/
|
|
146
|
-
export declare function getOrRegisterClient(dataDir: string, authServer: AuthorizationServerMetadata, redirectUri: string, fetchImpl?: typeof fetch, lookupImpl?: DnsLookupCb): Promise<RegisteredClient>;
|
|
147
|
-
/** Inputs required to assemble the authorization redirect URL. */
|
|
148
|
-
export interface AuthorizeUrlInput {
|
|
149
|
-
authServer: AuthorizationServerMetadata;
|
|
150
|
-
clientId: string;
|
|
151
|
-
redirectUri: string;
|
|
152
|
-
state: string;
|
|
153
|
-
codeChallenge: string;
|
|
154
|
-
scope?: string;
|
|
155
|
-
/** RFC 8707 resource indicator — narrows the issued token to the target MCP server. */
|
|
156
|
-
resource?: string;
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Assemble the authorization endpoint URL the user's browser must be directed to.
|
|
160
|
-
* Sets PKCE parameters (`code_challenge`, `code_challenge_method=S256`), the `state`
|
|
161
|
-
* CSRF token, and optionally the RFC 8707 `resource` indicator.
|
|
162
|
-
* @param input All parameters needed to build the URL.
|
|
163
|
-
* @returns The fully-qualified authorization URL as a string.
|
|
164
|
-
*/
|
|
165
|
-
export declare function buildAuthorizeUrl(input: AuthorizeUrlInput): string;
|
|
166
|
-
/** Inputs for the authorization-code → token exchange (RFC 6749 §4.1.3). */
|
|
167
|
-
export interface ExchangeCodeInput {
|
|
168
|
-
tokenEndpoint: string;
|
|
169
|
-
clientId: string;
|
|
170
|
-
clientSecret?: string;
|
|
171
|
-
redirectUri: string;
|
|
172
|
-
code: string;
|
|
173
|
-
codeVerifier: string;
|
|
174
|
-
/** RFC 8707 resource indicator to include in the token request. */
|
|
175
|
-
resource?: string;
|
|
176
|
-
}
|
|
177
|
-
/**
|
|
178
|
-
* Exchange an authorization code for access and refresh tokens (RFC 6749 §4.1.3).
|
|
179
|
-
* Includes the PKCE `code_verifier` and, when supplied, the RFC 8707 `resource`
|
|
180
|
-
* indicator. Throws when the token endpoint returns a non-2xx status.
|
|
181
|
-
* @param input The code-exchange parameters.
|
|
182
|
-
* @param fetchImpl Injectable fetch, defaults to the global `fetch`.
|
|
183
|
-
* @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
|
|
184
|
-
* guard (tests only — see `safeOAuthFetch`).
|
|
185
|
-
* @returns The token endpoint response containing at least an `access_token`.
|
|
186
|
-
*/
|
|
187
|
-
export declare function exchangeCodeForToken(input: ExchangeCodeInput, fetchImpl?: typeof fetch, lookupImpl?: DnsLookupCb): Promise<OAuthTokenResponse>;
|
|
188
|
-
/** Inputs for the refresh-token → new-access-token exchange (RFC 6749 §6). */
|
|
189
|
-
export interface RefreshTokenInput {
|
|
190
|
-
tokenEndpoint: string;
|
|
191
|
-
clientId: string;
|
|
192
|
-
clientSecret?: string;
|
|
193
|
-
refreshToken: string;
|
|
194
|
-
scope?: string;
|
|
195
|
-
/** RFC 8707 resource indicator, included when the original token was resource-scoped. */
|
|
196
|
-
resource?: string;
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* Exchange a refresh token for a new access token (RFC 6749 §6).
|
|
200
|
-
* Preserves scope and resource binding from the original authorization.
|
|
201
|
-
* Throws when the token endpoint returns a non-2xx status.
|
|
202
|
-
* @param input The refresh parameters.
|
|
203
|
-
* @param fetchImpl Injectable fetch, defaults to the global `fetch`.
|
|
204
|
-
* @param lookupImpl Injectable `dns.lookup` for the connection-time SSRF
|
|
205
|
-
* guard (tests only — see `safeOAuthFetch`).
|
|
206
|
-
* @returns A fresh `OAuthTokenResponse`; the server may issue a new refresh token.
|
|
207
|
-
*/
|
|
208
|
-
export declare function refreshAccessToken(input: RefreshTokenInput, fetchImpl?: typeof fetch, lookupImpl?: DnsLookupCb): Promise<OAuthTokenResponse>;
|
|
209
|
-
/**
|
|
210
|
-
* The OAuth dance is split across two HTTP requests on our side:
|
|
211
|
-
* 1. the "start" request — we mint state + verifier
|
|
212
|
-
* 2. the "callback" request — browser returns code + state
|
|
213
|
-
* State has to survive between (1) and (2) on the daemon. We keep it in a
|
|
214
|
-
* Map with a TTL sweeper; persistence isn't needed because the user has
|
|
215
|
-
* to complete auth in the same daemon process anyway (state is single-use).
|
|
216
|
-
*/
|
|
217
|
-
export declare class PendingAuthCache {
|
|
218
|
-
private readonly ttlMs;
|
|
219
|
-
private store;
|
|
220
|
-
private timer;
|
|
221
|
-
constructor(ttlMs?: number);
|
|
222
|
-
/**
|
|
223
|
-
* Store a pending auth state keyed by the OAuth `state` parameter.
|
|
224
|
-
* Starts the TTL sweeper if it is not already running.
|
|
225
|
-
* @param state The random `state` string from the authorization request.
|
|
226
|
-
* @param value The associated pending auth metadata to stash.
|
|
227
|
-
*/
|
|
228
|
-
put(state: string, value: PendingAuthState): void;
|
|
229
|
-
/** One-shot consume — any successful callback removes the state so a
|
|
230
|
-
* replay can't reuse it. */
|
|
231
|
-
consume(state: string): PendingAuthState | null;
|
|
232
|
-
/** Return the number of pending auth states currently held in the cache. */
|
|
233
|
-
size(): number;
|
|
234
|
-
/** Stop the background sweeper. Used by tests; production lets the
|
|
235
|
-
* timer ride on the process lifetime. */
|
|
236
|
-
stop(): void;
|
|
237
|
-
private startSweeper;
|
|
238
|
-
private sweep;
|
|
239
|
-
}
|
|
240
|
-
/** Inputs for the full pre-redirect OAuth dance. */
|
|
241
|
-
export interface BeginAuthInput {
|
|
242
|
-
/** The `McpServerConfig.id` of the server being authorized. */
|
|
243
|
-
serverId: string;
|
|
244
|
-
/** The MCP server endpoint URL (used for protected-resource discovery). */
|
|
245
|
-
serverUrl: string;
|
|
246
|
-
/** The OAuth redirect URI the daemon has registered. */
|
|
247
|
-
redirectUri: string;
|
|
248
|
-
/** The resolved data directory (for caching the client registration). */
|
|
249
|
-
dataDir: string;
|
|
250
|
-
scope?: string;
|
|
251
|
-
/** Injectable fetch implementation; defaults to the global `fetch`. */
|
|
252
|
-
fetchImpl?: typeof fetch;
|
|
253
|
-
/** Injectable `dns.lookup` for the connection-time SSRF guard (tests only). */
|
|
254
|
-
lookupImpl?: DnsLookupCb;
|
|
255
|
-
}
|
|
256
|
-
/** Result of `beginAuth`: everything needed to redirect the browser and later complete the flow. */
|
|
257
|
-
export interface BeginAuthResult {
|
|
258
|
-
/** The fully-qualified authorization URL to redirect the user's browser to. */
|
|
259
|
-
authorizeUrl: string;
|
|
260
|
-
/** The random `state` string; must be passed to `PendingAuthCache.put` before redirecting. */
|
|
261
|
-
state: string;
|
|
262
|
-
/** The pending-auth metadata to store in `PendingAuthCache` keyed by `state`. */
|
|
263
|
-
pending: PendingAuthState;
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* Run the entire pre-redirect half of the OAuth dance:
|
|
267
|
-
* discovery → DCR (cached) → PKCE → authorize URL.
|
|
268
|
-
*
|
|
269
|
-
* Returns everything the caller needs to (a) push the user's browser at the
|
|
270
|
-
* correct authorize URL, and (b) finish the flow when the callback hits.
|
|
271
|
-
*/
|
|
272
|
-
export declare function beginAuth(input: BeginAuthInput): Promise<BeginAuthResult>;
|
|
273
|
-
export {};
|
|
274
|
-
//# sourceMappingURL=oauth.d.ts.map
|
package/dist/core/oauth.js
DELETED
|
@@ -1,667 +0,0 @@
|
|
|
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
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
export interface WriteSecretFileOptions {
|
|
2
|
-
/** Injectable for tests only; defaults to `process.platform`. */
|
|
3
|
-
platform?: NodeJS.Platform;
|
|
4
|
-
}
|
|
5
|
-
/**
|
|
6
|
-
* Atomically write `contents` to `finalPath` via a same-directory temp file
|
|
7
|
-
* created exclusively with mode 0600. On POSIX platforms, verifies no
|
|
8
|
-
* group/other permission bit made it onto disk before renaming into place;
|
|
9
|
-
* throws (after removing the temp file) if verification fails or the write
|
|
10
|
-
* itself fails, so a caller never observes a partially-written or
|
|
11
|
-
* insufficiently-restricted secret file.
|
|
12
|
-
* @param finalPath Absolute path the secret file should end up at.
|
|
13
|
-
* @param contents UTF-8 text to write.
|
|
14
|
-
* @param options `platform` override for tests; defaults to `process.platform`.
|
|
15
|
-
*/
|
|
16
|
-
export declare function writeSecretFileAtomic(finalPath: string, contents: string, options?: WriteSecretFileOptions): Promise<void>;
|
|
17
|
-
//# sourceMappingURL=secure-write.d.ts.map
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module @jini-ai/mcp/core/secure-write
|
|
3
|
-
* Shared atomic, owner-only-permission (0600) secret-file writer used by
|
|
4
|
-
* every secret-bearing on-disk store in this package: the server config
|
|
5
|
-
* store (env vars / `Authorization` headers), the OAuth dynamic-client-
|
|
6
|
-
* registration cache (`clientSecret`), and the OAuth token store (bearer /
|
|
7
|
-
* refresh tokens, confidential-client secrets). CR-006 / SEC-RB-002.
|
|
8
|
-
*
|
|
9
|
-
* The previous pattern in each of those stores wrote the temp file with
|
|
10
|
-
* default permissions and either never restricted it (config, OAuth client
|
|
11
|
-
* cache) or `chmod(0600)`'d it *after* the atomic rename and silently
|
|
12
|
-
* continued when the chmod failed (tokens) — both leave a window where the
|
|
13
|
-
* file exists world/group-readable, and the latter makes the "protection"
|
|
14
|
-
* advisory rather than enforced. This writer instead:
|
|
15
|
-
*
|
|
16
|
-
* 1. Creates the temp file *exclusively* (`wx`) with mode 0600 supplied to
|
|
17
|
-
* the same `open()`/`writeFile()` call that creates it — POSIX applies
|
|
18
|
-
* the requested mode atomically at creation time, so there is no
|
|
19
|
-
* window where the file exists with a broader mode. This repo already
|
|
20
|
-
* relies on `writeFile`'s `mode` option alone for a secret file with no
|
|
21
|
-
* separate chmod step (see `packages/agent-runtime/src/prompt-file.ts`);
|
|
22
|
-
* this module follows that same precedent rather than inventing a new one.
|
|
23
|
-
* 2. On POSIX platforms, verifies the on-disk mode actually landed as
|
|
24
|
-
* owner-only before renaming into place, and fails closed — removing
|
|
25
|
-
* the temp file and throwing — if it did not (e.g. an unusual umask or
|
|
26
|
-
* a filesystem that doesn't honor `mode`).
|
|
27
|
-
* 3. Skips that verification on `win32`: Windows has no POSIX permission-
|
|
28
|
-
* bit model (`mode` there only toggles the read-only attribute), so
|
|
29
|
-
* there is nothing meaningful to enforce or fail closed on — matching
|
|
30
|
-
* this repo's only other cross-platform file-mode precedent, which
|
|
31
|
-
* also does not branch on Windows.
|
|
32
|
-
*
|
|
33
|
-
* Part of the MCP `core` kernel; not part of the package's public barrel —
|
|
34
|
-
* imported directly by sibling `core/*.ts` files.
|
|
35
|
-
*/
|
|
36
|
-
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
37
|
-
import { randomBytes } from 'node:crypto';
|
|
38
|
-
import path from 'node:path';
|
|
39
|
-
const SECRET_FILE_MODE = 0o600;
|
|
40
|
-
/**
|
|
41
|
-
* Atomically write `contents` to `finalPath` via a same-directory temp file
|
|
42
|
-
* created exclusively with mode 0600. On POSIX platforms, verifies no
|
|
43
|
-
* group/other permission bit made it onto disk before renaming into place;
|
|
44
|
-
* throws (after removing the temp file) if verification fails or the write
|
|
45
|
-
* itself fails, so a caller never observes a partially-written or
|
|
46
|
-
* insufficiently-restricted secret file.
|
|
47
|
-
* @param finalPath Absolute path the secret file should end up at.
|
|
48
|
-
* @param contents UTF-8 text to write.
|
|
49
|
-
* @param options `platform` override for tests; defaults to `process.platform`.
|
|
50
|
-
*/
|
|
51
|
-
export async function writeSecretFileAtomic(finalPath, contents, options = {}) {
|
|
52
|
-
const platform = options.platform ?? process.platform;
|
|
53
|
-
await mkdir(path.dirname(finalPath), { recursive: true });
|
|
54
|
-
const tmp = `${finalPath}.${randomBytes(4).toString('hex')}.tmp`;
|
|
55
|
-
try {
|
|
56
|
-
await writeFile(tmp, contents, { encoding: 'utf8', mode: SECRET_FILE_MODE, flag: 'wx' });
|
|
57
|
-
if (platform !== 'win32') {
|
|
58
|
-
const info = await stat(tmp);
|
|
59
|
-
const bits = info.mode & 0o777;
|
|
60
|
-
if ((bits & 0o077) !== 0) {
|
|
61
|
-
throw new Error(`refusing to persist secret-bearing file with non-owner-only permissions ` +
|
|
62
|
-
`(mode 0${bits.toString(8)}): ${finalPath}`);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
await rename(tmp, finalPath);
|
|
66
|
-
}
|
|
67
|
-
catch (err) {
|
|
68
|
-
await rm(tmp, { force: true }).catch(() => undefined);
|
|
69
|
-
throw err;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
//# sourceMappingURL=secure-write.js.map
|