Skip to content

Commit 63a8582

Browse files
Merge pull request #861 from corbitsdev/cl-7547-keep-mcp-oauth-tokens-after-browser-auth-across-sessions
Keep MCP OAuth tokens after browser auth across sessions
2 parents 5968d46 + efabd23 commit 63a8582

4 files changed

Lines changed: 180 additions & 51 deletions

File tree

src/mcp/auth-store.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,69 @@ describe("mcp auth-store", () => {
5959
).toBe(true);
6060
});
6161

62+
test("overlapping updateAuthState from two processes keeps tokens and PKCE", async () => {
63+
const home = await tempHome();
64+
await saveAuthState(
65+
linear,
66+
{
67+
clientInformation: {
68+
client_id: "c1",
69+
redirect_uris: ["http://127.0.0.1:1/callback"],
70+
client_id_issued_at: 1,
71+
},
72+
},
73+
home,
74+
);
75+
76+
const storePath = join(import.meta.dirname, "auth-store.ts");
77+
const barrier = join(home, "start");
78+
const script = `
79+
import { updateAuthState } from ${JSON.stringify(storePath)};
80+
const home = process.argv[1];
81+
const field = process.argv[2];
82+
const barrier = process.argv[3];
83+
const identity = { serverName: "linear", serverURL: "https://mcp.linear.app/mcp" };
84+
while (!(await Bun.file(barrier).exists())) await Bun.sleep(5);
85+
await updateAuthState(
86+
identity,
87+
(state) => {
88+
Bun.sleepSync(150);
89+
if (field === "tokens") {
90+
state.tokens = {
91+
access_token: "tok",
92+
token_type: "bearer",
93+
expires_in: 3600,
94+
refresh_token: "ref",
95+
};
96+
} else {
97+
state.codeVerifier = "verifier-from-other-session";
98+
}
99+
},
100+
home,
101+
);
102+
`;
103+
const processes = [
104+
Bun.spawn([process.execPath, "-e", script, "--", home, "tokens", barrier], {
105+
stdout: "ignore",
106+
stderr: "pipe",
107+
}),
108+
Bun.spawn([process.execPath, "-e", script, "--", home, "verifier", barrier], {
109+
stdout: "ignore",
110+
stderr: "pipe",
111+
}),
112+
];
113+
await Bun.sleep(50);
114+
await writeFile(barrier, "go");
115+
const exitCodes = await Promise.all(processes.map((child) => child.exited));
116+
const errors = await Promise.all(processes.map((child) => new Response(child.stderr).text()));
117+
expect(exitCodes, errors.join("\n")).toEqual([0, 0]);
118+
119+
const final = await loadAuthState(linear, home);
120+
expect(final.tokens?.access_token).toBe("tok");
121+
expect(final.codeVerifier).toBe("verifier-from-other-session");
122+
expect(final.clientInformation?.client_id).toBe("c1");
123+
});
124+
62125
test("concurrent saveAuthState calls do not throw ENOENT on temp rename", async () => {
63126
const home = await tempHome();
64127
await Promise.all(

src/mcp/auth-store.ts

Lines changed: 78 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { createHash } from "node:crypto";
2-
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2+
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
33
import { readFileSync } from "node:fs";
44
import { homedir } from "node:os";
55
import { dirname, join } from "node:path";
6+
import { setTimeout as delay } from "node:timers/promises";
67
import type {
78
OAuthClientInformationFull,
89
OAuthTokens,
@@ -126,6 +127,10 @@ export function tryLoadAuthStateSync(
126127
return parseAuthState(raw);
127128
}
128129

130+
function isEexist(err: unknown): boolean {
131+
return typeof err === "object" && err !== null && "code" in err && err.code === "EEXIST";
132+
}
133+
129134
// pid alone is not unique per call — concurrent saves in one process must not
130135
// share a temp path or the second rename hits ENOENT after the first moves it.
131136
let tmpWriteCounter = 0;
@@ -135,6 +140,70 @@ let tmpWriteCounter = 0;
135140
// session's saveCodeVerifier wiping another's just-written tokens).
136141
const updateChains = new Map<string, Promise<unknown>>();
137142

143+
const LOCK_STALE_MS = 5_000;
144+
const LOCK_RETRY_MS = 25;
145+
146+
async function acquireAuthFileLock(lockPath: string) {
147+
while (true) {
148+
try {
149+
return await open(lockPath, "wx", 0o600);
150+
} catch (err) {
151+
if (!isEexist(err)) throw err;
152+
try {
153+
const info = await stat(lockPath);
154+
if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
155+
try {
156+
await unlink(lockPath);
157+
} catch (unlinkErr) {
158+
if (!isEnoent(unlinkErr)) throw unlinkErr;
159+
}
160+
continue;
161+
}
162+
} catch (statErr) {
163+
if (isEnoent(statErr)) continue;
164+
throw statErr;
165+
}
166+
await delay(LOCK_RETRY_MS);
167+
}
168+
}
169+
}
170+
171+
async function withAuthFileLock<T>(path: string, op: () => Promise<T>): Promise<T> {
172+
const lockPath = `${path}.lock`;
173+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
174+
const lock = await acquireAuthFileLock(lockPath);
175+
try {
176+
return await op();
177+
} finally {
178+
try {
179+
await lock.close();
180+
} catch {
181+
// Close can fail if the handle was already torn down.
182+
}
183+
try {
184+
await unlink(lockPath);
185+
} catch {
186+
// Missing lock is fine; a leftover file is recovered as stale.
187+
}
188+
}
189+
}
190+
191+
function enqueueAuthFileOp<T>(path: string, op: () => Promise<T>): Promise<T> {
192+
const previous = updateChains.get(path) ?? Promise.resolve();
193+
const run = previous.then(
194+
() => withAuthFileLock(path, op),
195+
() => withAuthFileLock(path, op),
196+
);
197+
updateChains.set(
198+
path,
199+
run.then(
200+
() => undefined,
201+
() => undefined,
202+
),
203+
);
204+
return run;
205+
}
206+
138207
async function writeAuthFile(path: string, state: MCPAuthState): Promise<void> {
139208
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
140209
const tmp = `${path}.${process.pid}.${(tmpWriteCounter += 1)}.tmp`;
@@ -151,19 +220,7 @@ export async function saveAuthState(
151220
home: string = homedir(),
152221
): Promise<void> {
153222
const path = authFilePath(identity, home);
154-
const previous = updateChains.get(path) ?? Promise.resolve();
155-
const write = previous.then(
156-
() => writeAuthFile(path, state),
157-
() => writeAuthFile(path, state),
158-
);
159-
updateChains.set(
160-
path,
161-
write.then(
162-
() => undefined,
163-
() => undefined,
164-
),
165-
);
166-
await write;
223+
await enqueueAuthFileOp(path, () => writeAuthFile(path, state));
167224
}
168225

169226
// Load → mutate → save under the per-file chain. Mutator receives a mutable
@@ -174,49 +231,20 @@ export async function updateAuthState(
174231
home: string = homedir(),
175232
): Promise<MCPAuthState> {
176233
const path = authFilePath(identity, home);
177-
const previous = updateChains.get(path) ?? Promise.resolve();
178-
const run = previous.then(
179-
async () => {
180-
const state = await loadAuthState(identity, home);
181-
mutator(state);
182-
await writeAuthFile(path, state);
183-
return state;
184-
},
185-
async () => {
186-
const state = await loadAuthState(identity, home);
187-
mutator(state);
188-
await writeAuthFile(path, state);
189-
return state;
190-
},
191-
);
192-
updateChains.set(
193-
path,
194-
run.then(
195-
() => undefined,
196-
() => undefined,
197-
),
198-
);
199-
return run;
234+
return enqueueAuthFileOp(path, async () => {
235+
const state = await loadAuthState(identity, home);
236+
mutator(state);
237+
await writeAuthFile(path, state);
238+
return state;
239+
});
200240
}
201241

202242
export async function deleteAuthState(
203243
identity: MCPAuthIdentity,
204244
home: string = homedir(),
205245
): Promise<void> {
206246
const path = authFilePath(identity, home);
207-
const previous = updateChains.get(path) ?? Promise.resolve();
208-
const run = previous.then(
209-
() => unlinkAuthFile(path),
210-
() => unlinkAuthFile(path),
211-
);
212-
updateChains.set(
213-
path,
214-
run.then(
215-
() => undefined,
216-
() => undefined,
217-
),
218-
);
219-
await run;
247+
await enqueueAuthFileOp(path, () => unlinkAuthFile(path));
220248
}
221249

222250
async function unlinkAuthFile(path: string): Promise<void> {

src/mcp/oauth-provider.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,37 @@ describe("createOAuthProvider", () => {
160160
expect(disk.tokens).toBeUndefined();
161161
});
162162

163+
test("resetAuthorization does not delete a sibling's just-saved tokens", async () => {
164+
const home = await tempHome();
165+
const a = await createOAuthProvider({
166+
serverName: "linear",
167+
serverURL: linear.serverURL,
168+
redirectUrl: "http://127.0.0.1:62000/callback",
169+
onAuthURL: () => undefined,
170+
home,
171+
});
172+
const b = await createOAuthProvider({
173+
serverName: "linear",
174+
serverURL: linear.serverURL,
175+
redirectUrl: "http://127.0.0.1:60435/callback",
176+
onAuthURL: () => undefined,
177+
home,
178+
});
179+
180+
await a.saveTokens({
181+
access_token: "fresh",
182+
token_type: "bearer",
183+
expires_in: 3600,
184+
refresh_token: "fresh-refresh",
185+
});
186+
expect((await syncValue(a.tokens()))?.access_token).toBe("fresh");
187+
188+
await b.resetAuthorization();
189+
190+
expect((await syncValue(a.tokens()))?.access_token).toBe("fresh");
191+
expect((await loadAuthState(linear, home)).tokens?.access_token).toBe("fresh");
192+
});
193+
163194
test("isolates same-name providers by endpoint and persists the same identity", async () => {
164195
const home = await tempHome();
165196
const customURL = "https://custom.example/mcp";

src/mcp/oauth-provider.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ export async function createOAuthProvider(
198198
return stored.tokens;
199199
},
200200
saveTokens(tokens: OAuthTokens): Promise<void> {
201+
stored.tokens = tokens;
201202
return apply((state) => {
202203
state.tokens = tokens;
203204
});
@@ -220,8 +221,14 @@ export async function createOAuthProvider(
220221
},
221222
async resetAuthorization(): Promise<void> {
222223
oauthState = undefined;
224+
// Snapshot before the disk refresh so a session that never held tokens
225+
// cannot adopt a sibling's credentials and then delete them.
226+
const previous = stored.tokens?.access_token;
227+
refreshDurableFromDisk();
223228
await apply((state) => {
224-
delete state.tokens;
229+
if (state.tokens?.access_token === previous) {
230+
delete state.tokens;
231+
}
225232
delete state.codeVerifier;
226233
// Next browser flow needs a client registered for *this* loopback port.
227234
if (!redirectUrisInclude(state.clientInformation, opts.redirectUrl)) {

0 commit comments

Comments
 (0)