Skip to content

Commit a35fc4c

Browse files
committed
Re-read OAuth state from disk in the provider's sync getters
The MCP OAuth provider snapshotted auth state once at construction, so a session that re-authenticated never published its fresh tokens to sibling sessions: they kept serving a stale in-memory mirror and needlessly triggered the browser flow after a 401. The SDK requires tokens(), clientInformation(), and codeVerifier() to be synchronous, so the getters now guard with statSync and only readFileSync the auth file (via a new loadAuthStateSync that mirrors loadAuthState's parsing) when its mtime or size changed. A vanished or unreadable file keeps the in-memory mirror instead of throwing from a sync getter.
1 parent 6ea5969 commit a35fc4c

3 files changed

Lines changed: 111 additions & 7 deletions

File tree

src/mcp/auth-store.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createHash } from "node:crypto";
22
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3+
import { readFileSync } from "node:fs";
34
import { homedir } from "node:os";
45
import { dirname, join } from "node:path";
56
import type {
@@ -41,14 +42,37 @@ export function normalizeMCPServerURL(serverURL: string): string {
4142
return url.toString();
4243
}
4344

44-
function authFilePath(identity: MCPAuthIdentity, home: string): string {
45+
export function authFilePath(identity: MCPAuthIdentity, home: string = homedir()): string {
4546
const normalizedURL = normalizeMCPServerURL(identity.serverURL);
4647
const digest = createHash("sha256")
4748
.update(JSON.stringify([identity.serverName, normalizedURL]))
4849
.digest("hex");
4950
return join(mcpAuthDir(home), `${serverDisplaySlug(identity.serverName)}-${digest}.json`);
5051
}
5152

53+
// Synchronous mirror of loadAuthState for the SDK's sync getters (tokens(),
54+
// clientInformation(), codeVerifier()), which cannot await disk I/O. A missing or
55+
// corrupt file yields empty state, matching loadAuthState's tolerance.
56+
export function loadAuthStateSync(
57+
identity: MCPAuthIdentity,
58+
home: string = homedir(),
59+
): MCPAuthState {
60+
let raw: string;
61+
try {
62+
raw = readFileSync(authFilePath(identity, home), "utf8");
63+
} catch {
64+
return {};
65+
}
66+
try {
67+
const parsed: unknown = JSON.parse(raw);
68+
if (typeof parsed === "object" && parsed !== null) return parsed as MCPAuthState;
69+
} catch {
70+
// A corrupt auth file should not wedge the session; treat it as no state and
71+
// let a fresh authorization overwrite it.
72+
}
73+
return {};
74+
}
75+
5276
export async function loadAuthState(
5377
identity: MCPAuthIdentity,
5478
home: string = homedir(),

src/mcp/oauth-provider.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
22
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
5-
import { loadAuthState, saveAuthState } from "./auth-store.js";
5+
import { loadAuthState, saveAuthState, deleteAuthState } from "./auth-store.js";
66
import { createOAuthProvider } from "./oauth-provider.js";
77

88
async function tempHome(): Promise<string> {
@@ -208,6 +208,52 @@ describe("createOAuthProvider", () => {
208208
expect(await readFile(join(dir, ".json"), "utf8")).toBe(legacy);
209209
});
210210

211+
test("propagates tokens saved by one provider to an existing sibling provider", async () => {
212+
const home = await tempHome();
213+
const a = await createOAuthProvider({
214+
serverName: "linear",
215+
serverURL: linear.serverURL,
216+
redirectUrl: "http://127.0.0.1:1/callback",
217+
onAuthURL: () => undefined,
218+
home,
219+
});
220+
const b = await createOAuthProvider({
221+
serverName: "linear",
222+
serverURL: linear.serverURL,
223+
redirectUrl: "http://127.0.0.1:1/callback",
224+
onAuthURL: () => undefined,
225+
home,
226+
});
227+
expect(await syncValue(b.tokens())).toBeUndefined();
228+
229+
await a.saveTokens({
230+
access_token: "fresh",
231+
token_type: "bearer",
232+
expires_in: 3600,
233+
refresh_token: "fresh-refresh",
234+
});
235+
236+
expect((await syncValue(b.tokens()))?.access_token).toBe("fresh");
237+
});
238+
239+
test("sync getters fall back to the in-memory mirror when the auth file disappears", async () => {
240+
const home = await tempHome();
241+
const provider = await createOAuthProvider({
242+
serverName: "linear",
243+
serverURL: linear.serverURL,
244+
redirectUrl: "http://127.0.0.1:1/callback",
245+
onAuthURL: () => undefined,
246+
home,
247+
});
248+
await provider.saveTokens({ access_token: "tok", token_type: "bearer" });
249+
expect((await syncValue(provider.tokens()))?.access_token).toBe("tok");
250+
251+
await deleteAuthState(linear, home);
252+
253+
expect((await syncValue(provider.tokens()))?.access_token).toBe("tok");
254+
expect(await syncValue(provider.clientInformation())).toBeUndefined();
255+
});
256+
211257
test("does not delete scoped state whose filename stem is another provider name", async () => {
212258
const home = await tempHome();
213259
const dir = join(home, ".corbits", "mcp-auth");

src/mcp/oauth-provider.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@ import type {
55
OAuthClientMetadata,
66
OAuthTokens,
77
} from "@modelcontextprotocol/sdk/shared/auth.js";
8-
import { updateAuthState, type MCPAuthIdentity, type MCPAuthState } from "./auth-store.js";
8+
import {
9+
authFilePath,
10+
loadAuthStateSync,
11+
updateAuthState,
12+
type MCPAuthIdentity,
13+
type MCPAuthState,
14+
} from "./auth-store.js";
15+
import { statSync } from "node:fs";
16+
import { homedir } from "node:os";
917
import { MCP_CLIENT_NAME } from "../branding.js";
1018

1119
export interface OAuthProviderOptions {
@@ -54,22 +62,45 @@ export async function createOAuthProvider(
5462
serverName: opts.serverName,
5563
serverURL: opts.serverURL,
5664
};
65+
const home = opts.home ?? homedir();
5766
// Load + scrub stale DCR under the per-file chain so concurrent providers see
58-
// the same cleaned state. Mutations always re-read disk; this in-memory mirror
59-
// only serves the SDK's sync getters (tokens / clientInformation / codeVerifier).
67+
// the same cleaned state. Mutations always re-read disk; the in-memory mirror
68+
// serves the SDK's sync getters, refreshed from disk when the auth file
69+
// changes so tokens saved by another session are picked up immediately.
6070
const stored: MCPAuthState = await updateAuthState(
6171
identity,
6272
(state) => {
6373
dropStaleClientRegistration(state, opts.redirectUrl);
6474
},
65-
opts.home,
75+
home,
6676
);
6777

6878
const apply = async (mutator: (state: MCPAuthState) => void): Promise<void> => {
69-
const next = await updateAuthState(identity, mutator, opts.home);
79+
const next = await updateAuthState(identity, mutator, home);
7080
replaceStored(stored, next);
7181
};
7282

83+
// Cheap staleness guard: statSync per getter, sync read only when the file's
84+
// mtime or size changed. A vanished file keeps the mirror rather than throwing.
85+
const authPath = authFilePath(identity, home);
86+
let seenStamp: string | undefined;
87+
const refreshFromDisk = (): void => {
88+
let stamp: string | undefined;
89+
try {
90+
const stat = statSync(authPath);
91+
stamp = `${String(stat.mtimeMs)}:${String(stat.size)}`;
92+
} catch {
93+
// File gone (or unreadable): keep serving the in-memory mirror rather
94+
// than throwing from a sync getter.
95+
if (seenStamp === undefined) return;
96+
seenStamp = undefined;
97+
return;
98+
}
99+
if (stamp === seenStamp) return;
100+
seenStamp = stamp;
101+
replaceStored(stored, loadAuthStateSync(identity, home));
102+
};
103+
73104
let oauthState: string | undefined;
74105
return {
75106
get redirectUrl(): string {
@@ -89,6 +120,7 @@ export async function createOAuthProvider(
89120
};
90121
},
91122
clientInformation(): OAuthClientInformationMixed | undefined {
123+
refreshFromDisk();
92124
return stored.clientInformation;
93125
},
94126
saveClientInformation(info: OAuthClientInformationMixed): Promise<void> {
@@ -97,6 +129,7 @@ export async function createOAuthProvider(
97129
});
98130
},
99131
tokens(): OAuthTokens | undefined {
132+
refreshFromDisk();
100133
return stored.tokens;
101134
},
102135
saveTokens(tokens: OAuthTokens): Promise<void> {
@@ -115,6 +148,7 @@ export async function createOAuthProvider(
115148
});
116149
},
117150
codeVerifier(): string {
151+
refreshFromDisk();
118152
if (stored.codeVerifier === undefined)
119153
throw new Error("No PKCE code verifier saved for this authorization.");
120154
return stored.codeVerifier;

0 commit comments

Comments
 (0)