@yagni-app/code 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -6
- package/dist/claudePlugins.d.ts +3 -1
- package/dist/claudePlugins.js +3 -1
- package/dist/cli.js +12 -0
- package/dist/doctor.d.ts +28 -3
- package/dist/doctor.js +117 -7
- package/dist/extension/index.d.ts +5 -5
- package/dist/extension/index.js +89 -29
- package/dist/extension/mcp/approval.d.ts +45 -0
- package/dist/extension/mcp/approval.js +164 -0
- package/dist/extension/mcp/auth.d.ts +124 -0
- package/dist/extension/mcp/auth.js +560 -0
- package/dist/extension/mcp/authStore.d.ts +61 -0
- package/dist/extension/mcp/authStore.js +105 -0
- package/dist/extension/mcp/callbackPage.d.ts +31 -0
- package/dist/extension/mcp/callbackPage.js +222 -0
- package/dist/extension/mcp/cliConfig.d.ts +12 -0
- package/dist/extension/mcp/cliConfig.js +12 -0
- package/dist/extension/mcp/config.d.ts +131 -0
- package/dist/extension/mcp/config.js +309 -0
- package/dist/extension/mcp/log.d.ts +28 -0
- package/dist/extension/mcp/log.js +82 -0
- package/dist/extension/mcp/manager.d.ts +98 -0
- package/dist/extension/mcp/manager.js +273 -0
- package/dist/extension/mcp/names.d.ts +25 -0
- package/dist/extension/mcp/names.js +40 -0
- package/dist/extension/mcp/panel.d.ts +34 -0
- package/dist/extension/mcp/panel.js +258 -0
- package/dist/extension/mcp/prompts.d.ts +23 -0
- package/dist/extension/mcp/prompts.js +93 -0
- package/dist/extension/mcp/startup.d.ts +55 -0
- package/dist/extension/mcp/startup.js +150 -0
- package/dist/extension/mcp/tools.d.ts +31 -0
- package/dist/extension/mcp/tools.js +117 -0
- package/dist/extension/mcp/transports.d.ts +17 -0
- package/dist/extension/mcp/transports.js +44 -0
- package/dist/extension/permission/gate.d.ts +7 -0
- package/dist/extension/permission/gate.js +12 -5
- package/dist/extension/permission/guardian.d.ts +24 -5
- package/dist/extension/permission/guardian.js +162 -24
- package/dist/extension/pipeline/personas.js +5 -0
- package/dist/mcpCommand.d.ts +113 -0
- package/dist/mcpCommand.js +755 -0
- package/dist/otel.d.ts +36 -7
- package/dist/otel.js +90 -12
- package/dist/upgrade.d.ts +11 -2
- package/dist/upgrade.js +48 -8
- package/package.json +3 -2
- package/dist/extension/mcpTools.d.ts +0 -57
- package/dist/extension/mcpTools.js +0 -132
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.1 for MCP servers over HTTP/SSE transports, mirroring Claude Code's
|
|
3
|
+
* flow but keeping it to the application layer: the MCP SDK
|
|
4
|
+
* (`@modelcontextprotocol/sdk/client/auth.js`) already implements discovery,
|
|
5
|
+
* DCR, PKCE, token refresh, and the authorization-code exchange. This module
|
|
6
|
+
* is the glue the SDK needs from us:
|
|
7
|
+
*
|
|
8
|
+
* - `YagniAuthProvider` implements `OAuthClientProvider`: reflects our
|
|
9
|
+
* client metadata, persists DCR client info + tokens in the auth store,
|
|
10
|
+
* and captures the authorization URL when the SDK redirects.
|
|
11
|
+
* - `authenticate()` drives the interactive flow: SDK `auth()` (no code) →
|
|
12
|
+
* REDIRECT + authorization URL → open browser → loopback listener on
|
|
13
|
+
* 127.0.0.1 → validate `state` → SDK `auth()` with the code → AUTHORIZED.
|
|
14
|
+
* - `revokeServerTokens()` revokes access + refresh tokens (RFC 7009) when a
|
|
15
|
+
* server is removed — refresh first, access second, both best-effort.
|
|
16
|
+
*
|
|
17
|
+
* Loopback + redirect semantics follow RFC 8252: bind 127.0.0.1 on a random
|
|
18
|
+
* high port (or a fixed `oauth.callbackPort`), redirect_uri path `/callback`,
|
|
19
|
+
* and validate the returned `state` to prevent CSRF.
|
|
20
|
+
*/
|
|
21
|
+
import { createServer } from "node:http";
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { auth as sdkAuth, discoverOAuthServerInfo } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
24
|
+
import { expandServerEnv } from "./config.js";
|
|
25
|
+
import { runningUnderTest } from "../crashReport.js";
|
|
26
|
+
import { getStoredOAuthEntry, updateStoredOAuthEntry } from "./authStore.js";
|
|
27
|
+
import { deleteStoredOAuthEntry as clearStoredOAuthEntry } from "./authStore.js";
|
|
28
|
+
import { logMcpEvent, redactSensitiveUrlParams } from "./log.js";
|
|
29
|
+
import { renderAuthErrorPage, renderAuthStateMismatchPage, renderAuthSuccessPage, } from "./callbackPage.js";
|
|
30
|
+
/** Cancellation via an AbortSignal (Ctrl-C / Esc in the /mcp panel). */
|
|
31
|
+
export class AuthenticationCancelledError extends Error {
|
|
32
|
+
constructor(message = "OAuth cancelled") {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "AuthenticationCancelledError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Escape a provider-controlled string for interpolation into the loopback
|
|
39
|
+
* callback page's HTML. Dependency-free equivalent of the `xss` filter Claude
|
|
40
|
+
* Code applies at this exact spot: `&`, `<`, `>`, quotes → entities.
|
|
41
|
+
*/
|
|
42
|
+
export function escapeHtml(value) {
|
|
43
|
+
return value
|
|
44
|
+
.replace(/&/g, "&")
|
|
45
|
+
.replace(/</g, "<")
|
|
46
|
+
.replace(/>/g, ">")
|
|
47
|
+
.replace(/"/g, """)
|
|
48
|
+
.replace(/'/g, "'");
|
|
49
|
+
}
|
|
50
|
+
/** Five-minute loopback wait, matching Claude Code's `Authentication timeout`. */
|
|
51
|
+
export const OAuthFlowTimeoutMs = 5 * 60 * 1000; // exported so tests can shrink it via opts
|
|
52
|
+
/**
|
|
53
|
+
* Run one bounded sdkAuth phase (discovery+DCR, or the token exchange).
|
|
54
|
+
*
|
|
55
|
+
* The deadline and the caller's abort signal both drive ONE AbortController
|
|
56
|
+
* that is threaded into the SDK as `fetchFn` — so a hung authorization-server
|
|
57
|
+
* endpoint doesn't just lose the race, its socket is actually closed (an
|
|
58
|
+
* unbounded fetch would otherwise outlive the flow and leak the handle). On
|
|
59
|
+
* settle the controller is aborted regardless, as socket hygiene.
|
|
60
|
+
*
|
|
61
|
+
* Classification mirrors Claude Code: caller-abort → AuthenticationCancelledError
|
|
62
|
+
* (swallowed quietly upstream), deadline → `Authentication timeout (<phase>)`,
|
|
63
|
+
* anything else rethrown as-is.
|
|
64
|
+
*/
|
|
65
|
+
async function sdkAuthPhase(label, provider, args, opts) {
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
const forward = () => controller.abort();
|
|
68
|
+
opts.signal?.addEventListener("abort", forward, { once: true });
|
|
69
|
+
let timedOut = false;
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
timedOut = true;
|
|
72
|
+
controller.abort();
|
|
73
|
+
}, opts.timeoutMs);
|
|
74
|
+
timer.unref?.();
|
|
75
|
+
const fetchFn = (input, init) => {
|
|
76
|
+
const inner = init?.signal;
|
|
77
|
+
if (!inner)
|
|
78
|
+
return fetch(input, { ...init, signal: controller.signal });
|
|
79
|
+
const combo = new AbortController();
|
|
80
|
+
inner.addEventListener("abort", () => combo.abort(), { once: true });
|
|
81
|
+
controller.signal.addEventListener("abort", () => combo.abort(), { once: true });
|
|
82
|
+
return fetch(input, { ...init, signal: combo.signal });
|
|
83
|
+
};
|
|
84
|
+
try {
|
|
85
|
+
return await sdkAuth(provider, { ...args, fetchFn });
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (opts.signal?.aborted)
|
|
89
|
+
throw new AuthenticationCancelledError();
|
|
90
|
+
if (timedOut)
|
|
91
|
+
throw new Error(`Authentication timeout (${label})`);
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
controller.abort();
|
|
97
|
+
opts.signal?.removeEventListener("abort", forward);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Wrap the fetch used by an OAuth transport so a failed silent token-refresh
|
|
102
|
+
* POST is recorded with its HTTP status + OAuth error code — the exact thing
|
|
103
|
+
* that is otherwise invisible when a cached token's refresh fails at connect
|
|
104
|
+
* time and the SDK falls through to `REDIRECT`. The response is cloned before
|
|
105
|
+
* inspection so the SDK's own `parseErrorResponse` still reads the original
|
|
106
|
+
* body untouched.
|
|
107
|
+
*/
|
|
108
|
+
export function instrumentOAuthFetch(serverName, baseFetch = fetch) {
|
|
109
|
+
return async (input, init) => {
|
|
110
|
+
const response = await baseFetch(input, init);
|
|
111
|
+
try {
|
|
112
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
113
|
+
if (method === "POST" && init?.body != null) {
|
|
114
|
+
const body = typeof init.body === "string" ? init.body : init.body.toString();
|
|
115
|
+
if (body.includes("grant_type=refresh_token") && !response.ok) {
|
|
116
|
+
let errorCode = "unknown";
|
|
117
|
+
try {
|
|
118
|
+
const text = await response.clone().text();
|
|
119
|
+
const m = text.match(/"error"\s*:\s*"([^"]+)"/);
|
|
120
|
+
if (m)
|
|
121
|
+
errorCode = m[1];
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* body may be empty/unparseable */
|
|
125
|
+
}
|
|
126
|
+
logMcpEvent(serverName, "oauth_refresh_failed", { httpStatus: response.status, errorCode });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// instrumentation must never break the fetch chain
|
|
132
|
+
}
|
|
133
|
+
return response;
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Fallback loopback port for the transport-level (refresh-only) provider. */
|
|
137
|
+
export const DEFAULT_REDIRECT_PORT = 42000;
|
|
138
|
+
/**
|
|
139
|
+
* Build a provider for a server at connect time. The redirect URL is only used
|
|
140
|
+
* when the SDK needs to (re)authorize; refresh- and access-token paths ignore
|
|
141
|
+
* it, so a stable default port is fine here. `authenticate()` binds its own
|
|
142
|
+
* fresh port and builds a dedicated provider for the interactive flow.
|
|
143
|
+
*/
|
|
144
|
+
export function authProviderForServer(serverName, config, redirectUrl) {
|
|
145
|
+
const fixed = config.oauth?.callbackPort;
|
|
146
|
+
const uri = redirectUrl ?? buildRedirectUri(fixed ?? DEFAULT_REDIRECT_PORT);
|
|
147
|
+
return new YagniAuthProvider(serverName, config, uri);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The OAuthClientProvider the SDK drives. One instance per connection; the DCR
|
|
151
|
+
* client info and tokens persist in the auth store (not in memory) so a
|
|
152
|
+
* reconnect reads what the previous connection stored.
|
|
153
|
+
*/
|
|
154
|
+
export class YagniAuthProvider {
|
|
155
|
+
serverName;
|
|
156
|
+
config;
|
|
157
|
+
redirectUri;
|
|
158
|
+
_codeVerifier;
|
|
159
|
+
_state;
|
|
160
|
+
_authorizationUrl;
|
|
161
|
+
constructor(serverName, config, redirectUri) {
|
|
162
|
+
this.serverName = serverName;
|
|
163
|
+
this.config = config;
|
|
164
|
+
this.redirectUri = redirectUri;
|
|
165
|
+
}
|
|
166
|
+
get redirectUrl() {
|
|
167
|
+
return this.redirectUri;
|
|
168
|
+
}
|
|
169
|
+
/** The authorization URL the SDK last asked us to redirect to. */
|
|
170
|
+
get authorizationUrl() {
|
|
171
|
+
return this._authorizationUrl;
|
|
172
|
+
}
|
|
173
|
+
get clientMetadata() {
|
|
174
|
+
return {
|
|
175
|
+
client_name: `YAGNI Code (${this.serverName})`,
|
|
176
|
+
redirect_uris: [this.redirectUri],
|
|
177
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
178
|
+
response_types: ["code"],
|
|
179
|
+
token_endpoint_auth_method: "none",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async state() {
|
|
183
|
+
this._state ??= randomBytes(32).toString("base64url");
|
|
184
|
+
return this._state;
|
|
185
|
+
}
|
|
186
|
+
async clientInformation() {
|
|
187
|
+
const entry = getStoredOAuthEntry(this.serverName, this.config);
|
|
188
|
+
if (!entry?.clientId)
|
|
189
|
+
return undefined;
|
|
190
|
+
return {
|
|
191
|
+
client_id: entry.clientId,
|
|
192
|
+
...(entry.clientSecret ? { client_secret: entry.clientSecret } : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
async saveClientInformation(info) {
|
|
196
|
+
updateStoredOAuthEntry(this.serverName, this.config, (entry) => {
|
|
197
|
+
entry.clientId = info.client_id;
|
|
198
|
+
if (info.client_secret != null)
|
|
199
|
+
entry.clientSecret = info.client_secret;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async tokens() {
|
|
203
|
+
const entry = getStoredOAuthEntry(this.serverName, this.config);
|
|
204
|
+
if (!entry?.accessToken)
|
|
205
|
+
return undefined;
|
|
206
|
+
const expiresIn = entry.expiresAt ? Math.max(1, Math.floor((entry.expiresAt - Date.now()) / 1000)) : undefined;
|
|
207
|
+
return {
|
|
208
|
+
access_token: entry.accessToken,
|
|
209
|
+
token_type: "Bearer",
|
|
210
|
+
...(entry.refreshToken ? { refresh_token: entry.refreshToken } : {}),
|
|
211
|
+
...(expiresIn != null ? { expires_in: expiresIn } : {}),
|
|
212
|
+
...(entry.scopes ? { scope: entry.scopes.join(" ") } : {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
async saveTokens(tokens) {
|
|
216
|
+
const expiresIn = typeof tokens.expires_in === "number" ? tokens.expires_in : 3600;
|
|
217
|
+
updateStoredOAuthEntry(this.serverName, this.config, (entry) => {
|
|
218
|
+
entry.accessToken = tokens.access_token;
|
|
219
|
+
if (tokens.refresh_token)
|
|
220
|
+
entry.refreshToken = tokens.refresh_token;
|
|
221
|
+
entry.expiresAt = Date.now() + expiresIn * 1000;
|
|
222
|
+
if (tokens.scope)
|
|
223
|
+
entry.scopes = tokens.scope.split(/\s+/).filter(Boolean);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
async saveCodeVerifier(codeVerifier) {
|
|
227
|
+
this._codeVerifier = codeVerifier;
|
|
228
|
+
}
|
|
229
|
+
async codeVerifier() {
|
|
230
|
+
if (!this._codeVerifier)
|
|
231
|
+
throw new Error("No PKCE code verifier stored for this session");
|
|
232
|
+
return this._codeVerifier;
|
|
233
|
+
}
|
|
234
|
+
async redirectToAuthorization(authorizationUrl) {
|
|
235
|
+
this._authorizationUrl = authorizationUrl.toString();
|
|
236
|
+
logMcpEvent(this.serverName, "oauth_redirect", {
|
|
237
|
+
authorizationUrl: redactSensitiveUrlParams(this._authorizationUrl),
|
|
238
|
+
scope: authorizationUrl.searchParams.get("scope") ?? undefined,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async invalidateCredentials(scope) {
|
|
242
|
+
updateStoredOAuthEntry(this.serverName, this.config, (entry) => {
|
|
243
|
+
if (scope === "client" || scope === "all") {
|
|
244
|
+
delete entry.clientId;
|
|
245
|
+
delete entry.clientSecret;
|
|
246
|
+
}
|
|
247
|
+
if (scope === "tokens" || scope === "all") {
|
|
248
|
+
delete entry.accessToken;
|
|
249
|
+
delete entry.refreshToken;
|
|
250
|
+
delete entry.expiresAt;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
if (scope === "verifier" || scope === "all")
|
|
254
|
+
this._codeVerifier = undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function traceOutcomeFromError(err) {
|
|
258
|
+
if (err instanceof AuthenticationCancelledError)
|
|
259
|
+
return "cancelled";
|
|
260
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
261
|
+
if (msg.includes("Authentication timeout"))
|
|
262
|
+
return "timeout";
|
|
263
|
+
if (msg.includes("state mismatch"))
|
|
264
|
+
return "state_mismatch";
|
|
265
|
+
if (msg.includes("OAuth error:"))
|
|
266
|
+
return "provider_denied";
|
|
267
|
+
if (msg.includes("authorization code") || msg.includes("token") || msg.includes("exchange"))
|
|
268
|
+
return "token_exchange_failed";
|
|
269
|
+
return "error";
|
|
270
|
+
}
|
|
271
|
+
export async function authenticate(serverName, config, deps = {}, opts = {}) {
|
|
272
|
+
const openUrl = deps.openUrl ?? defaultOpenUrl;
|
|
273
|
+
const timeoutMs = opts.timeoutMs ?? OAuthFlowTimeoutMs;
|
|
274
|
+
// Discovery and the token exchange go to the ${VAR}-expanded URL when the
|
|
275
|
+
// caller provides one; the provider below keeps the RAW config so the
|
|
276
|
+
// auth-store key (sha256 of type+url+headers) is stable across env changes.
|
|
277
|
+
const serverUrl = opts.serverUrl ?? config.url;
|
|
278
|
+
// Bind the loopback port FIRST so redirect_uri and the listener agree.
|
|
279
|
+
const fixedPort = config.oauth?.callbackPort;
|
|
280
|
+
const port = fixedPort ?? (await findFreePort());
|
|
281
|
+
const redirectUri = buildRedirectUri(port);
|
|
282
|
+
const provider = new YagniAuthProvider(serverName, config, redirectUri);
|
|
283
|
+
// Step 1: discovery + DCR + PKCE challenge; SDK calls redirectToAuthorization
|
|
284
|
+
// (captured on the provider) and returns REDIRECT. Bounded: a hung discovery
|
|
285
|
+
// or DCR endpoint must fail the flow AND close its socket, not hold the
|
|
286
|
+
// command handler hostage.
|
|
287
|
+
let first;
|
|
288
|
+
try {
|
|
289
|
+
first = await sdkAuthPhase("discovery", provider, { serverUrl }, { signal: opts.signal, timeoutMs });
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
if (err instanceof AuthenticationCancelledError)
|
|
293
|
+
throw err;
|
|
294
|
+
logMcpEvent(serverName, "oauth_result", {
|
|
295
|
+
phase: "discovery",
|
|
296
|
+
outcome: traceOutcomeFromError(err),
|
|
297
|
+
errorDescription: err instanceof Error ? err.message.slice(0, 300) : String(err),
|
|
298
|
+
});
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
if (first !== "REDIRECT") {
|
|
302
|
+
// Cached tokens refreshed cleanly — already authorized.
|
|
303
|
+
const tokens = await provider.tokens();
|
|
304
|
+
logMcpEvent(serverName, "oauth_result", { outcome: "refreshed", redirectUri });
|
|
305
|
+
return { result: "AUTHORIZED", tokens };
|
|
306
|
+
}
|
|
307
|
+
const authorizationUrl = provider.authorizationUrl;
|
|
308
|
+
if (!authorizationUrl) {
|
|
309
|
+
logMcpEvent(serverName, "oauth_result", { phase: "redirect", outcome: "error", errorDescription: "SDK did not produce an authorization URL", redirectUri });
|
|
310
|
+
throw new Error("SDK did not produce an authorization URL");
|
|
311
|
+
}
|
|
312
|
+
// Bind the loopback listener FIRST, then open the browser (Claude Code
|
|
313
|
+
// parity: `server.listen` before `openBrowser`), so a fast redirect can never
|
|
314
|
+
// race the bind. The wait itself is bounded by the timeout below, so a source
|
|
315
|
+
// that errors before redirecting (Sentry's consent 500) can no longer wedge
|
|
316
|
+
// the command loop forever.
|
|
317
|
+
const state = await provider.state();
|
|
318
|
+
const codePromise = waitForCode(port, state, { signal: opts.signal, timeoutMs, onEvent: (evt, fields) => logMcpEvent(serverName, evt, { ...fields, redirectUri }) });
|
|
319
|
+
await openUrl(authorizationUrl);
|
|
320
|
+
let code;
|
|
321
|
+
try {
|
|
322
|
+
code = await codePromise;
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
logMcpEvent(serverName, "oauth_result", { phase: "callback", outcome: traceOutcomeFromError(err), errorDescription: err instanceof Error ? err.message.slice(0, 300) : String(err), redirectUri });
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
if (!code) {
|
|
329
|
+
logMcpEvent(serverName, "oauth_result", { phase: "callback", outcome: "provider_denied", errorDescription: "no authorization code received", redirectUri });
|
|
330
|
+
throw new Error("OAuth cancelled — no authorization code received");
|
|
331
|
+
}
|
|
332
|
+
// Step 3: exchange the code for tokens (SDK persists them via saveTokens).
|
|
333
|
+
// Bounded like discovery: a hung token endpoint must fail, not wedge.
|
|
334
|
+
let result;
|
|
335
|
+
try {
|
|
336
|
+
result = await sdkAuthPhase("token exchange", provider, { serverUrl, authorizationCode: code }, { signal: opts.signal, timeoutMs });
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
if (err instanceof AuthenticationCancelledError)
|
|
340
|
+
throw err;
|
|
341
|
+
logMcpEvent(serverName, "oauth_result", { phase: "exchange", outcome: traceOutcomeFromError(err), errorDescription: err instanceof Error ? err.message.slice(0, 300) : String(err) });
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
if (result !== "AUTHORIZED") {
|
|
345
|
+
logMcpEvent(serverName, "oauth_result", { phase: "exchange", outcome: "token_exchange_failed", errorDescription: `result: ${result}` });
|
|
346
|
+
throw new Error(`OAuth flow did not complete (result: ${result})`);
|
|
347
|
+
}
|
|
348
|
+
logMcpEvent(serverName, "oauth_result", { outcome: "authorized", redirectUri });
|
|
349
|
+
return { result, tokens: await provider.tokens() };
|
|
350
|
+
}
|
|
351
|
+
/** Find a free loopback port (OS-assigned). */
|
|
352
|
+
export function findFreePort() {
|
|
353
|
+
return new Promise((resolve, reject) => {
|
|
354
|
+
const probe = createServer();
|
|
355
|
+
probe.once("error", reject);
|
|
356
|
+
probe.listen(0, "127.0.0.1", () => {
|
|
357
|
+
const port = probe.address().port;
|
|
358
|
+
probe.close(() => resolve(port));
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
export function buildRedirectUri(port) {
|
|
363
|
+
return `http://127.0.0.1:${port}/callback`;
|
|
364
|
+
}
|
|
365
|
+
/** Listen on 127.0.0.1 for the callback; resolves with the code, rejects with a typed error on timeout/cancel/no-code. */
|
|
366
|
+
function waitForCode(port, expectedState, opts = {}) {
|
|
367
|
+
return new Promise((resolve, reject) => {
|
|
368
|
+
let server = null;
|
|
369
|
+
let timeoutId = null;
|
|
370
|
+
let settled = false;
|
|
371
|
+
const { onEvent } = opts;
|
|
372
|
+
const finish = (code) => {
|
|
373
|
+
if (settled)
|
|
374
|
+
return;
|
|
375
|
+
settled = true;
|
|
376
|
+
if (timeoutId)
|
|
377
|
+
clearTimeout(timeoutId);
|
|
378
|
+
if (server) {
|
|
379
|
+
try {
|
|
380
|
+
server.close();
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
/* best-effort */
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
resolve(code);
|
|
387
|
+
};
|
|
388
|
+
const fail = (err) => {
|
|
389
|
+
if (settled)
|
|
390
|
+
return;
|
|
391
|
+
settled = true;
|
|
392
|
+
if (timeoutId)
|
|
393
|
+
clearTimeout(timeoutId);
|
|
394
|
+
if (server) {
|
|
395
|
+
try {
|
|
396
|
+
server.close();
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
/* best-effort */
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
reject(err);
|
|
403
|
+
};
|
|
404
|
+
if (opts.signal) {
|
|
405
|
+
if (opts.signal.aborted) {
|
|
406
|
+
onEvent?.("oauth_cancelled", {});
|
|
407
|
+
fail(new AuthenticationCancelledError());
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
opts.signal.addEventListener("abort", () => {
|
|
411
|
+
onEvent?.("oauth_cancelled", {});
|
|
412
|
+
fail(new AuthenticationCancelledError());
|
|
413
|
+
}, { once: true });
|
|
414
|
+
}
|
|
415
|
+
server = createServer((req, res) => {
|
|
416
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
417
|
+
if (url.pathname !== "/callback") {
|
|
418
|
+
res.writeHead(404);
|
|
419
|
+
res.end();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const state = url.searchParams.get("state");
|
|
423
|
+
const error = url.searchParams.get("error");
|
|
424
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
425
|
+
const errorUri = url.searchParams.get("error_uri");
|
|
426
|
+
const code = url.searchParams.get("code") ?? undefined;
|
|
427
|
+
if (error) {
|
|
428
|
+
onEvent?.("oauth_callback_error", { errorCode: error, errorDescription, errorUri });
|
|
429
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
430
|
+
// The provider-controlled values are HTML-escaped: any local process
|
|
431
|
+
// can hit the loopback with a crafted ?error=<script> payload, and the
|
|
432
|
+
// page must never reflect it (same spot Claude Code sanitizes).
|
|
433
|
+
res.end(renderAuthErrorPage({
|
|
434
|
+
error: escapeHtml(error),
|
|
435
|
+
errorDescription: errorDescription ? escapeHtml(errorDescription) : undefined,
|
|
436
|
+
}));
|
|
437
|
+
finish(undefined);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (state !== expectedState) {
|
|
441
|
+
onEvent?.("oauth_callback_state_mismatch", {});
|
|
442
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
443
|
+
res.end(renderAuthStateMismatchPage());
|
|
444
|
+
fail(new Error("OAuth state mismatch - possible CSRF attack"));
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
onEvent?.("oauth_callback_code", {});
|
|
448
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
449
|
+
res.end(renderAuthSuccessPage());
|
|
450
|
+
finish(code);
|
|
451
|
+
});
|
|
452
|
+
server.on("error", () => {
|
|
453
|
+
onEvent?.("oauth_cancelled", {});
|
|
454
|
+
fail(new Error("OAuth callback server failed"));
|
|
455
|
+
});
|
|
456
|
+
server.listen(port, "127.0.0.1");
|
|
457
|
+
server.unref();
|
|
458
|
+
const timeoutMs = opts.timeoutMs ?? OAuthFlowTimeoutMs;
|
|
459
|
+
timeoutId = setTimeout(() => {
|
|
460
|
+
onEvent?.("oauth_timeout", {});
|
|
461
|
+
fail(new Error("Authentication timeout"));
|
|
462
|
+
}, timeoutMs);
|
|
463
|
+
timeoutId.unref();
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Revoke access + refresh tokens on the server (RFC 7009) — refresh first,
|
|
468
|
+
* access second, both best-effort. Public clients put client_id in the body
|
|
469
|
+
* (no Authorization header); a 401 falls back to Bearer auth for
|
|
470
|
+
* non-compliant servers (CC parity). Never throws.
|
|
471
|
+
*/
|
|
472
|
+
export async function revokeServerTokens(serverName, config, metadata, clientInfo, deps = {}) {
|
|
473
|
+
const fetchFn = deps.fetch ?? fetch;
|
|
474
|
+
const rawEndpoint = metadata.revocation_endpoint;
|
|
475
|
+
if (!rawEndpoint)
|
|
476
|
+
return;
|
|
477
|
+
const endpoint = rawEndpoint instanceof URL ? rawEndpoint.toString() : rawEndpoint;
|
|
478
|
+
const entry = getStoredOAuthEntry(serverName, config);
|
|
479
|
+
if (!entry)
|
|
480
|
+
return;
|
|
481
|
+
const clientId = clientInfo.client_id;
|
|
482
|
+
const secret = clientInfo.client_secret;
|
|
483
|
+
const revokeOne = async (token, hint) => {
|
|
484
|
+
if (!token)
|
|
485
|
+
return;
|
|
486
|
+
const body = new URLSearchParams({ token, token_type_hint: hint });
|
|
487
|
+
if (clientId)
|
|
488
|
+
body.set("client_id", clientId);
|
|
489
|
+
const baseHeaders = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
490
|
+
if (secret)
|
|
491
|
+
baseHeaders.Authorization = `Basic ${Buffer.from(`${clientId}:${secret}`).toString("base64")}`;
|
|
492
|
+
try {
|
|
493
|
+
let res = await fetchFn(endpoint, { method: "POST", headers: baseHeaders, body });
|
|
494
|
+
if (!secret && res.status === 401) {
|
|
495
|
+
res = await fetchFn(endpoint, {
|
|
496
|
+
method: "POST",
|
|
497
|
+
headers: { ...baseHeaders, Authorization: `Bearer ${token}` },
|
|
498
|
+
body,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
void res;
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// best-effort
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
await revokeOne(entry.refreshToken, "refresh_token");
|
|
508
|
+
await revokeOne(entry.accessToken, "access_token");
|
|
509
|
+
}
|
|
510
|
+
/** Best-effort browser open without a shell (injected in tests).
|
|
511
|
+
*
|
|
512
|
+
* A no-op under the test runner (crashReport.ts's runningUnderTest): a test
|
|
513
|
+
* that forgets to inject `openUrl` must not pop a real browser window on the
|
|
514
|
+
* developer's machine. Same philosophy as crash-report suppression — make the
|
|
515
|
+
* whole class of leak impossible rather than fixing one call site at a time.
|
|
516
|
+
*/
|
|
517
|
+
async function defaultOpenUrl(url) {
|
|
518
|
+
if (runningUnderTest())
|
|
519
|
+
return;
|
|
520
|
+
const { spawn } = await import("node:child_process");
|
|
521
|
+
const platform = process.platform;
|
|
522
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
523
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
524
|
+
try {
|
|
525
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// best-effort — the URL is surfaced to the user by the panel caller
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Revoke tokens + clear stored credentials for a server being removed. Runs
|
|
533
|
+
* RFC 9728/8414 discovery to find the revocation endpoint, revokes (refresh →
|
|
534
|
+
* access, best-effort), then deletes the local auth store entry. Callers use
|
|
535
|
+
* this so `yagni mcp remove` also logs the server out rather than orphaning
|
|
536
|
+
* live tokens on disk. Never throws — a failed revoke must not block removal.
|
|
537
|
+
*/
|
|
538
|
+
export async function revokeTokensOnRemove(serverName, config, deps = {}) {
|
|
539
|
+
try {
|
|
540
|
+
// Discovery needs the resolvable (${VAR}-expanded) URL; store operations
|
|
541
|
+
// below key off the raw config. Best-effort: an unresolvable var leaves
|
|
542
|
+
// the raw URL, discovery fails, and the local entry is still cleared.
|
|
543
|
+
const discoveryUrl = expandServerEnv(config).config.url;
|
|
544
|
+
const info = await discoverOAuthServerInfo(discoveryUrl, { fetchFn: deps.fetch });
|
|
545
|
+
const metadata = info.authorizationServerMetadata;
|
|
546
|
+
const entry = getStoredOAuthEntry(serverName, config);
|
|
547
|
+
if (metadata && entry?.clientId) {
|
|
548
|
+
const clientInfo = {
|
|
549
|
+
client_id: entry.clientId,
|
|
550
|
+
...(entry.clientSecret ? { client_secret: entry.clientSecret } : {}),
|
|
551
|
+
};
|
|
552
|
+
await revokeServerTokens(serverName, config, metadata, clientInfo, deps);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
// discovery or revoke failed — still clear locally below
|
|
557
|
+
}
|
|
558
|
+
clearStoredOAuthEntry(serverName, config);
|
|
559
|
+
}
|
|
560
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth token + client-credential store for MCP servers, pure file I/O (no
|
|
3
|
+
* network, no pi imports) so it is shared verbatim between the extension
|
|
4
|
+
* session and the `yagni mcp` CLI (revoke-on-remove reads it too).
|
|
5
|
+
*
|
|
6
|
+
* ~/.yagni-code/mcp-auth.json (0600 — access/refresh tokens and any
|
|
7
|
+
* pre-registered client secret are secrets)
|
|
8
|
+
*
|
|
9
|
+
* Entries are keyed `serverName|<sha256(type+url+headers)[:16]>` — the same
|
|
10
|
+
* key Claude Code derives via `getServerKey`, so a server whose URL/headers
|
|
11
|
+
* change invalidates its stored credentials automatically. Writes are atomic
|
|
12
|
+
* (temp + rename) and chmod 0600, exactly like the mcp.json config writes.
|
|
13
|
+
*/
|
|
14
|
+
import type { McpHttpServerConfig } from "./config.js";
|
|
15
|
+
export interface StoredOAuthEntry {
|
|
16
|
+
serverName: string;
|
|
17
|
+
serverUrl: string;
|
|
18
|
+
/** Client id from DCR, or a pre-registered `oauth.clientId`. */
|
|
19
|
+
clientId?: string;
|
|
20
|
+
/** Pre-registered client secret (`yagni mcp add --client-secret`), NOT a DCR secret. */
|
|
21
|
+
clientSecret?: string;
|
|
22
|
+
accessToken?: string;
|
|
23
|
+
refreshToken?: string;
|
|
24
|
+
/** Epoch ms when the access token expires (refresh happens before this). */
|
|
25
|
+
expiresAt?: number;
|
|
26
|
+
scopes?: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface McpAuthFile {
|
|
29
|
+
servers?: Record<string, StoredOAuthEntry>;
|
|
30
|
+
}
|
|
31
|
+
export declare function _setMcpAuthHomeForTest(dir: string | null): void;
|
|
32
|
+
export declare function mcpAuthPath(): string;
|
|
33
|
+
/**
|
|
34
|
+
* The stable key for one server's credentials. Hash covers type + url +
|
|
35
|
+
* headers (NOT the client id, matching Claude Code) so a URL/header change
|
|
36
|
+
* orphans old tokens rather than silently reusing them against a different
|
|
37
|
+
* endpoint.
|
|
38
|
+
*/
|
|
39
|
+
export declare function getServerKey(serverName: string, config: McpHttpServerConfig): string;
|
|
40
|
+
/** Read + parse the auth store; missing/unparseable → empty with error noted. */
|
|
41
|
+
export declare function readMcpAuth(): {
|
|
42
|
+
file: McpAuthFile;
|
|
43
|
+
errors: string[];
|
|
44
|
+
};
|
|
45
|
+
/** Atomic write + chmod 0600 (tokens/secrets must never be world-readable). */
|
|
46
|
+
export declare function writeMcpAuth(file: McpAuthFile): void;
|
|
47
|
+
/** Read one entry by server key; undefined when absent. */
|
|
48
|
+
export declare function getStoredOAuthEntry(serverName: string, config: McpHttpServerConfig): StoredOAuthEntry | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* Read-modify-write one entry. `mutate` receives the current entry (or a fresh
|
|
51
|
+
* one) and may edit it in place; returning `false` deletes it. Used by both
|
|
52
|
+
* the provider (save tokens/creds) and revocation (clear on remove).
|
|
53
|
+
*/
|
|
54
|
+
export declare function updateStoredOAuthEntry(serverName: string, config: McpHttpServerConfig, mutate: (entry: StoredOAuthEntry) => void | false): {
|
|
55
|
+
errors: string[];
|
|
56
|
+
};
|
|
57
|
+
/** Remove every credential entry (used by revoke-on-remove). */
|
|
58
|
+
export declare function deleteStoredOAuthEntry(serverName: string, config: McpHttpServerConfig): {
|
|
59
|
+
errors: string[];
|
|
60
|
+
};
|
|
61
|
+
//# sourceMappingURL=authStore.d.ts.map
|