mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Refreshes hold a per-server lock file from reading the tokens to saving new ones, and use tokens another process already refreshed. Rotating refresh tokens (Cloudflare) were lost when two pi processes refreshed at once, leaving the server needing sign-in. Closing a connection waits for a running refresh so shutdown does not drop rotated tokens.
84 lines
2.9 KiB
TypeScript
84 lines
2.9 KiB
TypeScript
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { InMemoryAuthStorageBackend } from "../src/core/auth-storage.ts";
|
|
import { createMcpAuthProvider, McpOAuthCredentialStore, signInMcpServer } from "../src/extensions/mcp/oauth.ts";
|
|
import { startOAuthMcpServer } from "./suite/mcp-oauth-server.ts";
|
|
|
|
describe("MCP OAuth refresh", () => {
|
|
const cleanups: (() => Promise<void> | void)[] = [];
|
|
|
|
afterEach(async () => {
|
|
while (cleanups.length > 0) await cleanups.pop()?.();
|
|
});
|
|
|
|
async function signedIn() {
|
|
const server = await startOAuthMcpServer();
|
|
cleanups.push(server.close);
|
|
const lockDir = mkdtempSync(join(tmpdir(), "pi-mcp-refresh-"));
|
|
cleanups.push(() => rmSync(lockDir, { recursive: true, force: true }));
|
|
// Stores sharing the credential file and lock directory stand in for separate pi processes.
|
|
const backend = new InMemoryAuthStorageBackend();
|
|
const process = () => {
|
|
const store = new McpOAuthCredentialStore(backend, lockDir).forServer(server.url);
|
|
const provider = createMcpAuthProvider({
|
|
serverUrl: server.url,
|
|
store,
|
|
settings: () => ({}),
|
|
onChallenge: () => {},
|
|
});
|
|
return { store, provider };
|
|
};
|
|
await signInMcpServer({
|
|
serverUrl: server.url,
|
|
store: process().store,
|
|
settings: {},
|
|
prompt: {
|
|
showAuthorizationUrl: (url) => void fetch(url),
|
|
promptForRedirectUrl: (signal) =>
|
|
new Promise((resolve) => signal.addEventListener("abort", () => resolve(undefined), { once: true })),
|
|
},
|
|
});
|
|
return { server, lockDir, process };
|
|
}
|
|
|
|
it("refreshes once when several processes find the same token rejected", async () => {
|
|
const { server, lockDir, process } = await signedIn();
|
|
server.expireAccessTokens();
|
|
const processes = [process(), process(), process()];
|
|
|
|
// The server rotates refresh tokens: a second refresh with refresh-1 would fail with invalid_grant.
|
|
await Promise.all(
|
|
processes.map(({ provider }) =>
|
|
provider.onUnauthorized?.({
|
|
response: new Response(null, { status: 401 }),
|
|
serverUrl: new URL(server.url),
|
|
fetch: globalThis.fetch,
|
|
token: "access-1",
|
|
}),
|
|
),
|
|
);
|
|
|
|
expect(server.log.filter((entry) => entry === "token refresh")).toHaveLength(1);
|
|
for (const { provider } of processes) expect(await provider.token()).toBe("access-2");
|
|
// The lock is released.
|
|
expect(readdirSync(lockDir)).toEqual([]);
|
|
});
|
|
|
|
it("waits for a running refresh to save the new tokens", async () => {
|
|
const { server, process } = await signedIn();
|
|
server.expireAccessTokens();
|
|
const { store, provider } = process();
|
|
|
|
const refresh = provider.onUnauthorized?.({
|
|
response: new Response(null, { status: 401 }),
|
|
serverUrl: new URL(server.url),
|
|
fetch: globalThis.fetch,
|
|
token: "access-1",
|
|
});
|
|
await provider.settled();
|
|
expect((await store.load())?.tokens?.access_token).toBe("access-2");
|
|
await refresh;
|
|
});
|
|
});
|