Skip to content

Commit f55e37f

Browse files
committed
fix(mcp): harden auth disk boundary and guard refresh-vs-reset race
1 parent 9f9c6ba commit f55e37f

3 files changed

Lines changed: 216 additions & 7 deletions

File tree

‎src/mcp/auth-store.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,28 @@ function isEnoent(err: unknown): boolean {
7777
);
7878
}
7979

80+
function isAuthRecord(value: unknown): value is Record<string, unknown> {
81+
return typeof value === "object" && value !== null && !Array.isArray(value);
82+
}
83+
84+
export function isValidAuthTokens(value: unknown): value is OAuthTokens {
85+
return isAuthRecord(value) && typeof value.access_token === "string";
86+
}
87+
8088
function parseAuthState(raw: string): MCPAuthState | undefined {
8189
try {
8290
const parsed: unknown = JSON.parse(raw);
83-
if (typeof parsed === "object" && parsed !== null)
84-
return parsed as MCPAuthState;
91+
// The discriminator is explicit shape, not "parses to object": arrays,
92+
// null, and primitives are corrupt. Tokens crossing from disk into memory
93+
// must be absent (a missing key is an explicit logout write) or an object
94+
// carrying a string access_token; anything else reads as unreadable so
95+
// the caller keeps its in-memory mirror and never stores the malformed
96+
// value.
97+
if (!isAuthRecord(parsed)) return undefined;
98+
const state = parsed as MCPAuthState;
99+
if (state.tokens !== undefined && !isValidAuthTokens(state.tokens))
100+
return undefined;
101+
return state;
85102
} catch {
86103
// A corrupt auth file should not wedge the session; treat it as no state and
87104
// let a fresh authorization overwrite it.

‎src/mcp/oauth-provider.test.ts‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,169 @@ describe("createOAuthProvider", () => {
738738
expect((await syncValue(provider.tokens()))?.access_token).toBe("tok");
739739
});
740740

741+
test("keeps the mirror when the auth file holds a JSON array", async () => {
742+
const home = await tempHome();
743+
const provider = await createOAuthProvider({
744+
serverName: "linear",
745+
serverURL: linear.serverURL,
746+
redirectUrl: "http://127.0.0.1:1/callback",
747+
onAuthURL: () => undefined,
748+
home,
749+
});
750+
await saveClient(provider, clientInfo(1));
751+
await provider.saveTokens({ access_token: "tok", token_type: "bearer" });
752+
753+
await writeFile(authFilePath(linear, home), "[]");
754+
755+
expect((await syncValue(provider.tokens()))?.access_token).toBe("tok");
756+
expect((await syncValue(provider.clientInformation()))?.client_id).toBe(
757+
"client-on-1",
758+
);
759+
});
760+
761+
test("treats malformed disk tokens as unreadable and never throws", async () => {
762+
const malformed = [
763+
`{"tokens":null}`,
764+
`{"tokens":"tok-a"}`,
765+
`{"tokens":{}}`,
766+
`{"tokens":[]}`,
767+
];
768+
for (const raw of malformed) {
769+
const home = await tempHome();
770+
const provider = await createOAuthProvider({
771+
serverName: "linear",
772+
serverURL: linear.serverURL,
773+
redirectUrl: "http://127.0.0.1:1/callback",
774+
onAuthURL: () => undefined,
775+
home,
776+
});
777+
await provider.saveTokens({ access_token: "tok", token_type: "bearer" });
778+
779+
await writeFile(authFilePath(linear, home), raw);
780+
781+
expect((await syncValue(provider.tokens()))?.access_token).toBe("tok");
782+
expect(await syncValue(provider.clientInformation())).toBeUndefined();
783+
}
784+
});
785+
786+
test("never throws when disk holds an other-port client with null tokens", async () => {
787+
const home = await tempHome();
788+
const provider = await createOAuthProvider({
789+
serverName: "linear",
790+
serverURL: linear.serverURL,
791+
redirectUrl: "http://127.0.0.1:62000/callback",
792+
onAuthURL: () => undefined,
793+
home,
794+
});
795+
await saveClient(provider, clientInfo(62000));
796+
await provider.saveTokens({
797+
access_token: "tok-a",
798+
token_type: "bearer",
799+
expires_in: 3600,
800+
refresh_token: "ref-a",
801+
});
802+
803+
await writeFile(
804+
authFilePath(linear, home),
805+
JSON.stringify({
806+
clientInformation: clientInfo(60435),
807+
tokens: null,
808+
}),
809+
);
810+
811+
expect((await syncValue(provider.tokens()))?.access_token).toBe("tok-a");
812+
expect((await syncValue(provider.clientInformation()))?.client_id).toBe(
813+
"client-on-62000",
814+
);
815+
});
816+
817+
test("a mid-refresh sibling logout is not resurrected by the refresh write", async () => {
818+
const home = await tempHome();
819+
let releaseRefresh!: () => void;
820+
const refreshGate = new Promise<void>((resolve) => {
821+
releaseRefresh = resolve;
822+
});
823+
let refreshPosted!: () => void;
824+
const refreshPostedGate = new Promise<void>((resolve) => {
825+
refreshPosted = resolve;
826+
});
827+
const fetchFn = async (
828+
url: string | URL,
829+
init?: RequestInit,
830+
): Promise<Response> => {
831+
const href = String(url);
832+
if (init?.method === "POST") {
833+
refreshPosted();
834+
await refreshGate;
835+
return new Response(
836+
JSON.stringify({
837+
access_token: "resurrected",
838+
token_type: "bearer",
839+
expires_in: 3600,
840+
refresh_token: "ref-b",
841+
}),
842+
{ status: 200, headers: { "content-type": "application/json" } },
843+
);
844+
}
845+
if (href.includes("oauth-protected-resource")) {
846+
return new Response(
847+
JSON.stringify({
848+
resource: "https://mcp.linear.app/mcp",
849+
authorization_servers: ["https://mcp.linear.app"],
850+
}),
851+
{ status: 200, headers: { "content-type": "application/json" } },
852+
);
853+
}
854+
if (
855+
href.includes("oauth-authorization-server") ||
856+
href.includes("openid-configuration")
857+
) {
858+
return new Response(
859+
JSON.stringify({
860+
issuer: "https://mcp.linear.app",
861+
authorization_endpoint: "https://mcp.linear.app/authorize",
862+
token_endpoint: "https://mcp.linear.app/oauth/token",
863+
response_types_supported: ["code"],
864+
}),
865+
{ status: 200, headers: { "content-type": "application/json" } },
866+
);
867+
}
868+
return new Response(null, { status: 404 });
869+
};
870+
871+
const a = await createOAuthProvider({
872+
serverName: "linear",
873+
serverURL: linear.serverURL,
874+
redirectUrl: "http://127.0.0.1:62000/callback",
875+
onAuthURL: () => undefined,
876+
home,
877+
fetchFn,
878+
});
879+
await saveClient(a, clientInfo(62000));
880+
await a.saveTokens({
881+
access_token: "stale",
882+
token_type: "bearer",
883+
expires_in: 1,
884+
refresh_token: "ref-a",
885+
});
886+
const b = await createOAuthProvider({
887+
serverName: "linear",
888+
serverURL: linear.serverURL,
889+
redirectUrl: "http://127.0.0.1:62000/callback",
890+
onAuthURL: () => undefined,
891+
home,
892+
});
893+
894+
const pending = a.refreshToken("ref-a");
895+
await refreshPostedGate;
896+
await b.resetAuthorization();
897+
releaseRefresh();
898+
await pending;
899+
900+
expect((await loadAuthState(linear, home)).tokens).toBeUndefined();
901+
expect(await syncValue(a.tokens())).toBeUndefined();
902+
});
903+
741904
test("keeps the mirror through an unreadable auth file and recovers once readable", async () => {
742905
const home = await tempHome();
743906
const provider = await createOAuthProvider({

‎src/mcp/oauth-provider.ts‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
2121
import {
2222
authFilePath,
23+
isValidAuthTokens,
2324
tryLoadAuthStateSync,
2425
updateAuthState,
2526
type MCPAuthIdentity,
@@ -45,9 +46,9 @@ function redirectUrisInclude(
4546
info: OAuthClientInformationFull | undefined,
4647
redirectUrl: string,
4748
): boolean {
48-
const uris = info?.redirect_uris;
49-
if (uris === undefined || uris.length === 0) return true;
50-
return uris.includes(redirectUrl);
49+
const uris: unknown = info?.redirect_uris;
50+
if (!Array.isArray(uris) || uris.length === 0) return true;
51+
return (uris as unknown[]).includes(redirectUrl);
5152
}
5253

5354
function isAbortError(err: unknown): boolean {
@@ -77,12 +78,20 @@ function shouldAdoptClient(
7778
next: MCPAuthState,
7879
redirectUrl: string,
7980
): boolean {
80-
if (next.clientInformation === undefined) return false;
81+
// Malformed disk must never throw or poison the mirror: adoption requires a
82+
// client object and valid tokens. next.tokens is boundary-validated upstream,
83+
// but the guards below keep this total even if that contract is bypassed.
84+
if (
85+
typeof next.clientInformation !== "object" ||
86+
next.clientInformation === null ||
87+
Array.isArray(next.clientInformation)
88+
)
89+
return false;
8190
if (redirectUrisInclude(next.clientInformation, redirectUrl)) return true;
8291
// Other-port DCR is a sibling's in-progress registration unless they also
8392
// published new tokens (completed re-auth).
8493
return (
85-
next.tokens !== undefined &&
94+
isValidAuthTokens(next.tokens) &&
8695
next.tokens.access_token !== stored.tokens?.access_token
8796
);
8897
}
@@ -264,6 +273,16 @@ export async function createOAuthProvider(
264273
},
265274
refreshToken: async (refreshToken: string): Promise<OAuthTokens> => {
266275
try {
276+
// Snapshot the durable access token before the network round-trip. A
277+
// sibling resetAuthorization (logout) or rotation that lands mid-refresh
278+
// must win over this stale grant: the post-refresh write re-checks disk
279+
// and drops the refreshed tokens instead of resurrecting revoked ones.
280+
// There is no server-side revocation, so the durable file is the only
281+
// source of truth for "logged out". Sync read: refreshToken callers may
282+
// busy-wait on microtasks alone, which an async disk read could never
283+
// resolve behind.
284+
const preRefreshAccess = tryLoadAuthStateSync(identity, home)?.tokens
285+
?.access_token;
267286
const fetchFn = opts.fetchFn;
268287
if (
269288
authorizationServerMetadata === undefined ||
@@ -312,9 +331,19 @@ export async function createOAuthProvider(
312331
resource: resourceURL,
313332
...(fetchFn === undefined ? {} : { fetchFn }),
314333
});
334+
let refreshSuperseded = false;
315335
await apply((state) => {
336+
if (state.tokens?.access_token !== preRefreshAccess) {
337+
refreshSuperseded = true;
338+
return;
339+
}
316340
state.tokens = tokens;
317341
});
342+
if (refreshSuperseded) {
343+
const disk = tryLoadAuthStateSync(identity, home);
344+
if (disk?.tokens !== undefined) stored.tokens = disk.tokens;
345+
else delete stored.tokens;
346+
}
318347
return tokens;
319348
} catch (err) {
320349
if (isAbortError(err) || err instanceof UnauthorizedError) throw err;

0 commit comments

Comments
 (0)