Skip to content

Commit 755903b

Browse files
committed
Retry OAuth disk refresh after a failed read
The refresh guard recorded the file stamp before reading it, so a persistently unreadable auth file (e.g. chmod 000) wiped the in-memory mirror with empty state and never retried. The stamp is now committed only after a successful read, leaving it stale so the next getter call retries. loadAuthStateSync also now matches loadAuthState's error contract: missing (ENOENT) or corrupt files yield empty state, while other read errors propagate to the caller instead of being swallowed.
1 parent a35fc4c commit 755903b

3 files changed

Lines changed: 47 additions & 7 deletions

File tree

src/mcp/auth-store.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,26 @@ export function authFilePath(identity: MCPAuthIdentity, home: string = homedir()
5151
}
5252

5353
// 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.
54+
// clientInformation(), codeVerifier()), which cannot await disk I/O. Tolerates
55+
// a missing (ENOENT) or corrupt file with empty state, matching loadAuthState;
56+
// other read errors propagate to the caller.
5657
export function loadAuthStateSync(
5758
identity: MCPAuthIdentity,
5859
home: string = homedir(),
5960
): MCPAuthState {
6061
let raw: string;
6162
try {
6263
raw = readFileSync(authFilePath(identity, home), "utf8");
63-
} catch {
64-
return {};
64+
} catch (err) {
65+
if (
66+
typeof err === "object" &&
67+
err !== null &&
68+
"code" in err &&
69+
(err as { code?: unknown }).code === "ENOENT"
70+
) {
71+
return {};
72+
}
73+
throw err;
6574
}
6675
try {
6776
const parsed: unknown = JSON.parse(raw);

src/mcp/oauth-provider.test.ts

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

88
async function tempHome(): Promise<string> {
@@ -254,6 +254,29 @@ describe("createOAuthProvider", () => {
254254
expect(await syncValue(provider.clientInformation())).toBeUndefined();
255255
});
256256

257+
test("keeps the mirror through an unreadable auth file and recovers once readable", async () => {
258+
const home = await tempHome();
259+
const provider = await createOAuthProvider({
260+
serverName: "linear",
261+
serverURL: linear.serverURL,
262+
redirectUrl: "http://127.0.0.1:1/callback",
263+
onAuthURL: () => undefined,
264+
home,
265+
});
266+
await provider.saveTokens({ access_token: "tok", token_type: "bearer" });
267+
268+
// Force a stat change so the mtime guard actually attempts the read.
269+
const path = authFilePath(linear, home);
270+
await appendFile(path, " ");
271+
await chmod(path, 0o000);
272+
expect((await syncValue(provider.tokens()))?.access_token).toBe("tok");
273+
expect((await syncValue(provider.tokens()))?.access_token).toBe("tok");
274+
275+
await chmod(path, 0o600);
276+
await saveAuthState(linear, { tokens: { access_token: "fresh", token_type: "bearer" } }, home);
277+
expect((await syncValue(provider.tokens()))?.access_token).toBe("fresh");
278+
});
279+
257280
test("does not delete scoped state whose filename stem is another provider name", async () => {
258281
const home = await tempHome();
259282
const dir = join(home, ".corbits", "mcp-auth");

src/mcp/oauth-provider.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,16 @@ export async function createOAuthProvider(
9797
return;
9898
}
9999
if (stamp === seenStamp) return;
100+
let next: MCPAuthState;
101+
try {
102+
next = loadAuthStateSync(identity, home);
103+
} catch {
104+
// Unreadable rather than missing: leave the stamp stale so the next
105+
// getter call retries instead of pinning an empty state over the mirror.
106+
return;
107+
}
100108
seenStamp = stamp;
101-
replaceStored(stored, loadAuthStateSync(identity, home));
109+
replaceStored(stored, next);
102110
};
103111

104112
let oauthState: string | undefined;

0 commit comments

Comments
 (0)