pi-mcp-extension 1.7.2 → 1.8.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/package.json +1 -1
- package/src/auth-lock.ts +426 -0
- package/src/callback-server.ts +28 -47
- package/src/callback-url.ts +136 -0
- package/src/index.ts +151 -33
- package/src/oauth-provider.ts +146 -90
package/package.json
CHANGED
package/src/auth-lock.ts
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-process per-server interactive OAuth authorization lock.
|
|
3
|
+
*
|
|
4
|
+
* Uses an atomic lock file (<hash>.json.lock, mode 0600) containing a
|
|
5
|
+
* versioned JSON record (PID, hostname, random nonce, creation timestamp)
|
|
6
|
+
* plus a periodic mtime heartbeat. Automatically reclaims locks left behind
|
|
7
|
+
* by crashed or SIGKILLed processes using a nonce-verified rename protocol.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
open,
|
|
12
|
+
readFile,
|
|
13
|
+
stat,
|
|
14
|
+
rename,
|
|
15
|
+
unlink,
|
|
16
|
+
link,
|
|
17
|
+
utimes,
|
|
18
|
+
mkdir,
|
|
19
|
+
chmod,
|
|
20
|
+
} from "node:fs/promises";
|
|
21
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
22
|
+
import { hostname as osHostname } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { agentDir } from "./config.js";
|
|
27
|
+
|
|
28
|
+
// ─── Constants & Schema ───────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export const HEARTBEAT_MS = 30_000;
|
|
31
|
+
export const STALE_MS = 90_000; // 3 × 30s heartbeat
|
|
32
|
+
|
|
33
|
+
export const LockRecordSchema = z.object({
|
|
34
|
+
v: z.literal(1),
|
|
35
|
+
pid: z.number().int().positive(),
|
|
36
|
+
hostname: z.string().min(1),
|
|
37
|
+
nonce: z.string().min(1),
|
|
38
|
+
createdAt: z.number(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export type LockRecord = z.output<typeof LockRecordSchema>;
|
|
42
|
+
|
|
43
|
+
// ─── Errors ───────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export class AuthRequiredError extends Error {
|
|
46
|
+
constructor(
|
|
47
|
+
serverName: string,
|
|
48
|
+
message = `Authorization required for ${serverName}. Connect in an interactive Pi session or use /mcp:auth ${serverName}.`,
|
|
49
|
+
) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "AuthRequiredError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class AuthLockError extends AuthRequiredError {
|
|
56
|
+
constructor(serverName: string, details?: string) {
|
|
57
|
+
const message = details
|
|
58
|
+
? details.startsWith("Authorization lock") || details.startsWith("Another Pi process")
|
|
59
|
+
? details
|
|
60
|
+
: `Another Pi process (${details}) is authorizing ${serverName}. Retry after it finishes.`
|
|
61
|
+
: `Another Pi process is authorizing ${serverName}. Retry after it finishes.`;
|
|
62
|
+
super(serverName, message);
|
|
63
|
+
this.name = "AuthLockError";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Dependency Injection Types ───────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export interface AuthLockFileHandle {
|
|
70
|
+
writeFile(data: string): Promise<void>;
|
|
71
|
+
sync(): Promise<void>;
|
|
72
|
+
close(): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AuthLockFs {
|
|
76
|
+
open(path: string, flags: string, mode?: number): Promise<AuthLockFileHandle>;
|
|
77
|
+
readFile(path: string, encoding: BufferEncoding): Promise<string>;
|
|
78
|
+
stat(path: string): Promise<{ mtimeMs: number }>;
|
|
79
|
+
rename(oldPath: string, newPath: string): Promise<void>;
|
|
80
|
+
unlink(path: string): Promise<void>;
|
|
81
|
+
link(existingPath: string, newPath: string): Promise<void>;
|
|
82
|
+
utimes(path: string, atime: number | string | Date, mtime: number | string | Date): Promise<void>;
|
|
83
|
+
mkdir(path: string, options?: { recursive?: boolean; mode?: number }): Promise<string | undefined | void>;
|
|
84
|
+
chmod(path: string, mode: number): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AuthLockTimer {
|
|
88
|
+
unref?: () => void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface AuthLockClock {
|
|
92
|
+
now(): number;
|
|
93
|
+
setInterval(callback: () => void, ms: number): ReturnType<typeof setInterval> | AuthLockTimer;
|
|
94
|
+
clearInterval(timer: any): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface AuthLockDeps {
|
|
98
|
+
fs: AuthLockFs;
|
|
99
|
+
clock: AuthLockClock;
|
|
100
|
+
pid: number;
|
|
101
|
+
hostname: string;
|
|
102
|
+
isPidAlive: (pid: number) => boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface AcquireAuthLockOptions {
|
|
106
|
+
onLost?: (() => void) | undefined;
|
|
107
|
+
deps?: Partial<AuthLockDeps> | undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type AuthLockHandle = (() => Promise<void>) & {
|
|
111
|
+
assertHeld: () => Promise<void>;
|
|
112
|
+
isLost: () => boolean;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// ─── Path & Default Dependency Helpers ────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export function authDir(storageDir?: string): string {
|
|
118
|
+
return storageDir ?? join(agentDir(), "mcp-auth");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function statePath(serverName: string, storageDir?: string): string {
|
|
122
|
+
// Hash the server name to avoid filesystem issues with special chars
|
|
123
|
+
const hash = createHash("sha256").update(serverName).digest("hex").slice(0, 16);
|
|
124
|
+
return join(authDir(storageDir), `${hash}.json`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function lockPath(serverName: string, storageDir?: string): string {
|
|
128
|
+
return `${statePath(serverName, storageDir)}.lock`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function defaultIsPidAlive(pid: number): boolean {
|
|
132
|
+
try {
|
|
133
|
+
process.kill(pid, 0);
|
|
134
|
+
return true;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createDefaultAuthLockDeps(): AuthLockDeps {
|
|
141
|
+
return {
|
|
142
|
+
fs: {
|
|
143
|
+
open: (path, flags, mode) => open(path, flags, mode),
|
|
144
|
+
readFile: (path, encoding) => readFile(path, encoding),
|
|
145
|
+
stat: (path) => stat(path),
|
|
146
|
+
rename: (oldPath, newPath) => rename(oldPath, newPath),
|
|
147
|
+
unlink: (path) => unlink(path),
|
|
148
|
+
link: (existingPath, newPath) => link(existingPath, newPath),
|
|
149
|
+
utimes: (path, atime, mtime) => utimes(path, atime, mtime),
|
|
150
|
+
mkdir: (path, options) => mkdir(path, options),
|
|
151
|
+
chmod: (path, mode) => chmod(path, mode),
|
|
152
|
+
},
|
|
153
|
+
clock: {
|
|
154
|
+
now: () => Date.now(),
|
|
155
|
+
setInterval: (cb, ms) => setInterval(cb, ms),
|
|
156
|
+
clearInterval: (timer) => clearInterval(timer),
|
|
157
|
+
},
|
|
158
|
+
pid: process.pid,
|
|
159
|
+
hostname: osHostname(),
|
|
160
|
+
isPidAlive: defaultIsPidAlive,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Pure Staleness Policy ────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
export function parseLockRecord(raw: string): LockRecord | null {
|
|
167
|
+
if (!raw) return null;
|
|
168
|
+
try {
|
|
169
|
+
const parsed: unknown = JSON.parse(raw);
|
|
170
|
+
const result = LockRecordSchema.safeParse(parsed);
|
|
171
|
+
return result.success ? result.data : null;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Pure policy determining whether an existing lock file is stale and safe to reclaim.
|
|
179
|
+
*/
|
|
180
|
+
export function isLockStale(
|
|
181
|
+
record: LockRecord | null,
|
|
182
|
+
mtimeMs: number,
|
|
183
|
+
now: number,
|
|
184
|
+
self: { hostname: string; isPidAlive: (pid: number) => boolean },
|
|
185
|
+
): boolean {
|
|
186
|
+
if (now - mtimeMs > STALE_MS) {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
if (record === null) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (record.hostname === self.hostname && !self.isPidAlive(record.pid)) {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function formatHolderDetails(record: LockRecord | null): string | undefined {
|
|
199
|
+
if (!record) return undefined;
|
|
200
|
+
return `pid ${record.pid} on ${record.hostname}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── Lock Acquisition, Heartbeat & Release ────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
206
|
+
|
|
207
|
+
export async function acquireAuthLock(
|
|
208
|
+
serverName: string,
|
|
209
|
+
storageDir?: string,
|
|
210
|
+
options?: AcquireAuthLockOptions,
|
|
211
|
+
): Promise<AuthLockHandle> {
|
|
212
|
+
const defaults = createDefaultAuthLockDeps();
|
|
213
|
+
const deps: AuthLockDeps = {
|
|
214
|
+
fs: options?.deps?.fs ?? defaults.fs,
|
|
215
|
+
clock: options?.deps?.clock ?? defaults.clock,
|
|
216
|
+
pid: options?.deps?.pid ?? defaults.pid,
|
|
217
|
+
hostname: options?.deps?.hostname ?? defaults.hostname,
|
|
218
|
+
isPidAlive: options?.deps?.isPidAlive ?? defaults.isPidAlive,
|
|
219
|
+
};
|
|
220
|
+
const usingRealFs = options?.deps?.fs === undefined;
|
|
221
|
+
|
|
222
|
+
const directory = authDir(storageDir);
|
|
223
|
+
await deps.fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
224
|
+
await deps.fs.chmod(directory, 0o700);
|
|
225
|
+
const path = lockPath(serverName, storageDir);
|
|
226
|
+
|
|
227
|
+
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
228
|
+
const myRecord: LockRecord = {
|
|
229
|
+
v: 1,
|
|
230
|
+
pid: deps.pid,
|
|
231
|
+
hostname: deps.hostname,
|
|
232
|
+
nonce: randomUUID(),
|
|
233
|
+
createdAt: deps.clock.now(),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const file = await deps.fs.open(path, "wx", 0o600);
|
|
238
|
+
try {
|
|
239
|
+
await file.writeFile(JSON.stringify(myRecord));
|
|
240
|
+
await file.sync();
|
|
241
|
+
} catch (writeErr) {
|
|
242
|
+
await file.close().catch(() => {});
|
|
243
|
+
await deps.fs.unlink(path).catch(() => {});
|
|
244
|
+
throw writeErr;
|
|
245
|
+
}
|
|
246
|
+
await file.close();
|
|
247
|
+
|
|
248
|
+
let released = false;
|
|
249
|
+
let lost = false;
|
|
250
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | AuthLockTimer | null = null;
|
|
251
|
+
|
|
252
|
+
const stopHeartbeat = () => {
|
|
253
|
+
if (heartbeatTimer !== null) {
|
|
254
|
+
deps.clock.clearInterval(heartbeatTimer);
|
|
255
|
+
heartbeatTimer = null;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const onExit = () => {
|
|
260
|
+
if (released || lost || !usingRealFs) return;
|
|
261
|
+
try {
|
|
262
|
+
const raw = readFileSync(path, "utf8");
|
|
263
|
+
const current = parseLockRecord(raw);
|
|
264
|
+
if (current && current.nonce === myRecord.nonce) {
|
|
265
|
+
unlinkSync(path);
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
// Best-effort cleanup on process exit
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const markLost = () => {
|
|
273
|
+
if (lost || released) return;
|
|
274
|
+
lost = true;
|
|
275
|
+
stopHeartbeat();
|
|
276
|
+
process.removeListener("exit", onExit);
|
|
277
|
+
options?.onLost?.();
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
process.once("exit", onExit);
|
|
281
|
+
|
|
282
|
+
heartbeatTimer = deps.clock.setInterval(async () => {
|
|
283
|
+
if (released || lost) return;
|
|
284
|
+
let current: LockRecord | null = null;
|
|
285
|
+
try {
|
|
286
|
+
const raw = await deps.fs.readFile(path, "utf8");
|
|
287
|
+
current = parseLockRecord(raw);
|
|
288
|
+
} catch {
|
|
289
|
+
current = null;
|
|
290
|
+
}
|
|
291
|
+
if (released || lost) return;
|
|
292
|
+
if (!current || current.nonce !== myRecord.nonce) {
|
|
293
|
+
markLost();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const nowSec = deps.clock.now() / 1000;
|
|
297
|
+
try {
|
|
298
|
+
await deps.fs.utimes(path, nowSec, nowSec);
|
|
299
|
+
} catch {
|
|
300
|
+
if (!released && !lost) {
|
|
301
|
+
markLost();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}, HEARTBEAT_MS);
|
|
305
|
+
heartbeatTimer.unref?.();
|
|
306
|
+
|
|
307
|
+
const assertHeld = async (): Promise<void> => {
|
|
308
|
+
if (lost || released) {
|
|
309
|
+
throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
310
|
+
}
|
|
311
|
+
let current: LockRecord | null = null;
|
|
312
|
+
try {
|
|
313
|
+
const raw = await deps.fs.readFile(path, "utf8");
|
|
314
|
+
current = parseLockRecord(raw);
|
|
315
|
+
} catch {
|
|
316
|
+
current = null;
|
|
317
|
+
}
|
|
318
|
+
if (!current || current.nonce !== myRecord.nonce) {
|
|
319
|
+
markLost();
|
|
320
|
+
throw new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const unlock = async (): Promise<void> => {
|
|
325
|
+
if (released) return;
|
|
326
|
+
released = true;
|
|
327
|
+
stopHeartbeat();
|
|
328
|
+
process.removeListener("exit", onExit);
|
|
329
|
+
if (lost) return;
|
|
330
|
+
let current: LockRecord | null = null;
|
|
331
|
+
try {
|
|
332
|
+
const raw = await deps.fs.readFile(path, "utf8");
|
|
333
|
+
current = parseLockRecord(raw);
|
|
334
|
+
} catch {
|
|
335
|
+
current = null;
|
|
336
|
+
}
|
|
337
|
+
if (current && current.nonce === myRecord.nonce) {
|
|
338
|
+
await deps.fs.unlink(path).catch(() => {});
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
return Object.assign(unlock, {
|
|
343
|
+
assertHeld,
|
|
344
|
+
isLost: () => lost,
|
|
345
|
+
});
|
|
346
|
+
} catch (err) {
|
|
347
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Lock file already exists — inspect and check staleness
|
|
352
|
+
let statResult: { mtimeMs: number };
|
|
353
|
+
try {
|
|
354
|
+
statResult = await deps.fs.stat(path);
|
|
355
|
+
} catch (statErr) {
|
|
356
|
+
if ((statErr as NodeJS.ErrnoException).code === "ENOENT") {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
throw statErr;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let record: LockRecord | null = null;
|
|
363
|
+
try {
|
|
364
|
+
const raw = await deps.fs.readFile(path, "utf8");
|
|
365
|
+
record = parseLockRecord(raw);
|
|
366
|
+
} catch (readErr) {
|
|
367
|
+
if ((readErr as NodeJS.ErrnoException).code === "ENOENT") {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
record = null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const stale = isLockStale(record, statResult.mtimeMs, deps.clock.now(), {
|
|
374
|
+
hostname: deps.hostname,
|
|
375
|
+
isPidAlive: deps.isPidAlive,
|
|
376
|
+
});
|
|
377
|
+
if (!stale) {
|
|
378
|
+
throw new AuthLockError(serverName, formatHolderDetails(record));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Reclaim protocol: move stale candidate aside, verify identity, then delete or restore
|
|
382
|
+
const movedPath = `${path}.stale-${randomUUID()}`;
|
|
383
|
+
try {
|
|
384
|
+
await deps.fs.rename(path, movedPath);
|
|
385
|
+
} catch (renameErr) {
|
|
386
|
+
if ((renameErr as NodeJS.ErrnoException).code === "ENOENT") {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
throw renameErr;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let movedRecord: LockRecord | null = null;
|
|
393
|
+
try {
|
|
394
|
+
const movedRaw = await deps.fs.readFile(movedPath, "utf8");
|
|
395
|
+
movedRecord = parseLockRecord(movedRaw);
|
|
396
|
+
} catch {
|
|
397
|
+
movedRecord = null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let matchesStale = false;
|
|
401
|
+
if (record !== null) {
|
|
402
|
+
matchesStale = movedRecord !== null && movedRecord.nonce === record.nonce;
|
|
403
|
+
} else {
|
|
404
|
+
try {
|
|
405
|
+
const movedStat = await deps.fs.stat(movedPath);
|
|
406
|
+
matchesStale = movedRecord === null && movedStat.mtimeMs === statResult.mtimeMs;
|
|
407
|
+
} catch {
|
|
408
|
+
matchesStale = false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (matchesStale) {
|
|
413
|
+
await deps.fs.unlink(movedPath).catch(() => {});
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
await deps.fs.link(movedPath, path).catch(() => {});
|
|
418
|
+
await deps.fs.unlink(movedPath).catch(() => {});
|
|
419
|
+
throw new AuthLockError(serverName, formatHolderDetails(movedRecord));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
throw new AuthLockError(serverName);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export { acquireAuthLock as acquireLock };
|
package/src/callback-server.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http";
|
|
9
9
|
import type { Socket } from "net";
|
|
10
10
|
import { URL } from "url";
|
|
11
|
+
import { CallbackUrlValidationError, isLocalCallbackHost, parseCallbackUrl } from "./callback-url.js";
|
|
11
12
|
|
|
12
13
|
interface PendingAuth {
|
|
13
14
|
resolve: (code: string) => void;
|
|
@@ -73,72 +74,52 @@ function escapeHtml(text: string): string {
|
|
|
73
74
|
* Handle incoming HTTP requests to the callback server.
|
|
74
75
|
*/
|
|
75
76
|
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
77
|
+
let parsed: ReturnType<typeof parseCallbackUrl>;
|
|
78
|
+
try {
|
|
79
|
+
const requestUrl = new URL(req.url || "/", "http://localhost").toString();
|
|
80
|
+
parsed = parseCallbackUrl(requestUrl, {
|
|
81
|
+
expectedState: (state) => pendingAuths.has(state),
|
|
82
|
+
});
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (err instanceof CallbackUrlValidationError) {
|
|
85
|
+
if (err.reason === "wrong_path") {
|
|
86
|
+
res.writeHead(404, { "Content-Type": "text/plain", "Connection": "close" });
|
|
87
|
+
res.end("Not found");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
|
|
91
|
+
res.end(HTML_ERROR(err.message));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
93
94
|
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
|
|
94
|
-
res.end(HTML_ERROR(
|
|
95
|
+
res.end(HTML_ERROR("Invalid callback request"));
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
// Handle OAuth errors
|
|
99
|
-
if (error) {
|
|
100
|
-
const errorMsg = errorDescription || error;
|
|
100
|
+
if ("error" in parsed) {
|
|
101
101
|
res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
|
|
102
|
-
res.end(HTML_ERROR(
|
|
103
|
-
if (pendingAuths.has(state)) {
|
|
104
|
-
const pending = pendingAuths.get(state)!;
|
|
102
|
+
res.end(HTML_ERROR(parsed.error));
|
|
103
|
+
if (pendingAuths.has(parsed.state)) {
|
|
104
|
+
const pending = pendingAuths.get(parsed.state)!;
|
|
105
105
|
clearTimeout(pending.timeout);
|
|
106
|
-
pendingAuths.delete(state);
|
|
107
|
-
setTimeout(() => pending.reject(new Error(
|
|
106
|
+
pendingAuths.delete(parsed.state);
|
|
107
|
+
setTimeout(() => pending.reject(new Error(parsed.error)), 0);
|
|
108
108
|
}
|
|
109
109
|
return;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
if (!code) {
|
|
114
|
-
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
|
|
115
|
-
res.end(HTML_ERROR("No authorization code provided"));
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Validate state parameter
|
|
120
|
-
if (!pendingAuths.has(state)) {
|
|
121
|
-
const errorMsg = "Invalid or expired state parameter - potential CSRF attack";
|
|
122
|
-
res.writeHead(400, { "Content-Type": "text/html", "Connection": "close" });
|
|
123
|
-
res.end(HTML_ERROR(errorMsg));
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const pending = pendingAuths.get(state)!;
|
|
112
|
+
const pending = pendingAuths.get(parsed.state)!;
|
|
128
113
|
|
|
129
114
|
// Clear timeout and resolve the pending promise
|
|
130
115
|
clearTimeout(pending.timeout);
|
|
131
|
-
pendingAuths.delete(state);
|
|
132
|
-
pending.resolve(code);
|
|
116
|
+
pendingAuths.delete(parsed.state);
|
|
117
|
+
pending.resolve(parsed.code);
|
|
133
118
|
|
|
134
119
|
res.writeHead(200, { "Content-Type": "text/html", "Connection": "close" });
|
|
135
120
|
res.end(HTML_SUCCESS);
|
|
136
121
|
}
|
|
137
122
|
|
|
138
|
-
function isLocalCallbackHost(hostname: string): boolean {
|
|
139
|
-
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
|
140
|
-
}
|
|
141
|
-
|
|
142
123
|
async function closeCurrentServer(): Promise<void> {
|
|
143
124
|
if (!server) return;
|
|
144
125
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure URL validation and parsing helpers for MCP OAuth authorization and callbacks.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type CallbackUrlValidationReason =
|
|
6
|
+
| "invalid_url"
|
|
7
|
+
| "wrong_origin"
|
|
8
|
+
| "wrong_path"
|
|
9
|
+
| "missing_state"
|
|
10
|
+
| "invalid_state"
|
|
11
|
+
| "missing_code";
|
|
12
|
+
|
|
13
|
+
export class CallbackUrlValidationError extends Error {
|
|
14
|
+
readonly reason: CallbackUrlValidationReason;
|
|
15
|
+
|
|
16
|
+
constructor(reason: CallbackUrlValidationReason, message: string) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "CallbackUrlValidationError";
|
|
19
|
+
this.reason = reason;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ParseCallbackUrlOptions {
|
|
24
|
+
redirectUrl?: string | URL | undefined;
|
|
25
|
+
expectedState?: string | ((state: string) => boolean) | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isLocalCallbackHost(hostname: string): boolean {
|
|
29
|
+
return (
|
|
30
|
+
hostname === "localhost" ||
|
|
31
|
+
hostname === "127.0.0.1" ||
|
|
32
|
+
hostname === "::1" ||
|
|
33
|
+
hostname === "[::1]"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validate that an OAuth authorization URL is safe to pass to a system browser opener.
|
|
39
|
+
* Only allows `https:` URLs or loopback `http:` URLs (`localhost`, `127.0.0.1`, `::1`, `[::1]`).
|
|
40
|
+
*/
|
|
41
|
+
export function isSafeAuthorizationUrl(input: string | URL): boolean {
|
|
42
|
+
let url: URL;
|
|
43
|
+
try {
|
|
44
|
+
url = input instanceof URL ? input : new URL(input);
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (url.protocol === "https:") {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (url.protocol === "http:" && isLocalCallbackHost(url.hostname)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse and validate an OAuth callback URL (either from an incoming HTTP callback request
|
|
62
|
+
* or from a user-pasted redirect URL).
|
|
63
|
+
*/
|
|
64
|
+
export function parseCallbackUrl(
|
|
65
|
+
input: string,
|
|
66
|
+
options: ParseCallbackUrlOptions = {},
|
|
67
|
+
): { state: string; code: string } | { state: string; error: string } {
|
|
68
|
+
const trimmed = input.trim();
|
|
69
|
+
let url: URL;
|
|
70
|
+
try {
|
|
71
|
+
url = new URL(trimmed);
|
|
72
|
+
} catch {
|
|
73
|
+
throw new CallbackUrlValidationError("invalid_url", "Invalid redirect URL");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (options.redirectUrl !== undefined) {
|
|
77
|
+
const expected =
|
|
78
|
+
options.redirectUrl instanceof URL
|
|
79
|
+
? options.redirectUrl
|
|
80
|
+
: new URL(String(options.redirectUrl));
|
|
81
|
+
if (
|
|
82
|
+
url.protocol !== expected.protocol ||
|
|
83
|
+
url.hostname !== expected.hostname ||
|
|
84
|
+
url.port !== expected.port
|
|
85
|
+
) {
|
|
86
|
+
throw new CallbackUrlValidationError(
|
|
87
|
+
"wrong_origin",
|
|
88
|
+
`Redirect URL origin must match ${expected.origin}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (url.pathname !== expected.pathname) {
|
|
92
|
+
throw new CallbackUrlValidationError(
|
|
93
|
+
"wrong_path",
|
|
94
|
+
`Redirect URL path must be ${expected.pathname}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
} else if (url.pathname !== "/callback") {
|
|
98
|
+
throw new CallbackUrlValidationError("wrong_path", "Redirect URL path must be /callback");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const code = url.searchParams.get("code");
|
|
102
|
+
const state = url.searchParams.get("state");
|
|
103
|
+
const error = url.searchParams.get("error");
|
|
104
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
105
|
+
|
|
106
|
+
if (!state) {
|
|
107
|
+
throw new CallbackUrlValidationError(
|
|
108
|
+
"missing_state",
|
|
109
|
+
"Missing required state parameter - potential CSRF attack",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof options.expectedState === "string" && state !== options.expectedState) {
|
|
114
|
+
throw new CallbackUrlValidationError(
|
|
115
|
+
"invalid_state",
|
|
116
|
+
"Invalid or expired state parameter - potential CSRF attack",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (error) {
|
|
121
|
+
return { state, error: errorDescription || error };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!code) {
|
|
125
|
+
throw new CallbackUrlValidationError("missing_code", "No authorization code provided");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof options.expectedState === "function" && !options.expectedState(state)) {
|
|
129
|
+
throw new CallbackUrlValidationError(
|
|
130
|
+
"invalid_state",
|
|
131
|
+
"Invalid or expired state parameter - potential CSRF attack",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { state, code };
|
|
136
|
+
}
|