|
1 | | -// Selfhost repros for two MCP OAuth bugs seen with a DCR connection whose |
2 | | -// refresh token is rejected by the provider as `invalid_grant`. |
| 1 | +// Selfhost repros for MCP OAuth bugs seen with a DCR connection whose |
| 2 | +// refresh token is rejected by the provider as `invalid_grant`, including the |
| 3 | +// reconnect journey: completing Reconnect must refresh the health verdict on |
| 4 | +// the page without a hard reload. |
3 | 5 | import { randomBytes } from "node:crypto"; |
| 6 | +import { createServer } from "node:http"; |
4 | 7 |
|
5 | 8 | import { Effect } from "effect"; |
6 | 9 | import { expect } from "@effect/vitest"; |
@@ -48,31 +51,136 @@ const requiredRedirect = (response: Response, from: string): string => { |
48 | 51 | return new URL(location, from).toString(); |
49 | 52 | }; |
50 | 53 |
|
| 54 | +/** The test server's login page is plain text with Basic-auth POST — nothing a |
| 55 | + * browser can click. Complete it out of band and hand back the callback URL. */ |
| 56 | +const submitProviderLogin = async (loginUrl: string): Promise<string> => { |
| 57 | + const credentials = Buffer.from("alice:password").toString("base64"); |
| 58 | + const response = await fetch(loginUrl, { |
| 59 | + method: "POST", |
| 60 | + redirect: "manual", |
| 61 | + headers: { authorization: `Basic ${credentials}` }, |
| 62 | + }); |
| 63 | + const location = response.headers.get("location"); |
| 64 | + if (response.status !== 302 || !location) { |
| 65 | + throw new Error(`provider login did not redirect (${response.status})`); |
| 66 | + } |
| 67 | + return new URL(location, loginUrl).toString(); |
| 68 | +}; |
| 69 | + |
51 | 70 | const completeAuthorization = (authorizationUrl: string) => |
52 | 71 | Effect.promise(async () => { |
53 | 72 | const login = await fetch(authorizationUrl, { redirect: "manual" }); |
54 | 73 | const loginUrl = requiredRedirect(login, authorizationUrl); |
55 | | - const credentials = Buffer.from("alice:password").toString("base64"); |
56 | | - const callback = await fetch(loginUrl, { |
57 | | - method: "POST", |
58 | | - headers: { authorization: `Basic ${credentials}` }, |
59 | | - redirect: "manual", |
60 | | - }); |
61 | | - const callbackUrl = requiredRedirect(callback, loginUrl); |
| 74 | + const callbackUrl = await submitProviderLogin(loginUrl); |
62 | 75 | const parsed = new URL(callbackUrl); |
63 | 76 | const code = parsed.searchParams.get("code"); |
64 | 77 | if (!code) throw new Error(`OAuth callback did not include a code: ${callbackUrl}`); |
65 | 78 | return { code }; |
66 | 79 | }); |
67 | 80 |
|
68 | | -const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) => |
| 81 | +/** AS whose refresh grants are dead forever — every token it mints is already |
| 82 | + * expired and refresh is rejected as `invalid_grant`. */ |
| 83 | +const serveDeadGrantOAuthServer = () => |
| 84 | + serveOAuthTestServer({ |
| 85 | + scopes: ["channels:history", "users:read"], |
| 86 | + supportRefresh: false, |
| 87 | + tokenExpiresInSeconds: 0, |
| 88 | + invalidRefreshTokenDescription: "Grant not found", |
| 89 | + }); |
| 90 | + |
| 91 | +interface GrantRevocationGate { |
| 92 | + /** Token endpoint to register with executor instead of the real one. */ |
| 93 | + readonly tokenUrl: string; |
| 94 | + /** The provider comes back: minted tokens get their real lifetime and |
| 95 | + * refresh grants are honored again. */ |
| 96 | + readonly restore: () => void; |
| 97 | + readonly refreshRejections: () => number; |
| 98 | + readonly close: () => void; |
| 99 | +} |
| 100 | + |
| 101 | +/** Token-endpoint proxy in front of the test AS. While "revoked" it behaves |
| 102 | + * like a provider whose grants are dead — authorization_code exchanges still |
| 103 | + * succeed but mint already-expired tokens, and refresh grants are rejected |
| 104 | + * with `invalid_grant` — and after `restore()` it is a plain passthrough. The |
| 105 | + * test server itself cannot flip behavior after construction, and this repro |
| 106 | + * needs "expired now, healthy after a fresh reconnect". */ |
| 107 | +const serveGrantRevocationGate = (upstreamTokenUrl: string) => |
| 108 | + Effect.acquireRelease( |
| 109 | + Effect.callback<GrantRevocationGate>((resume) => { |
| 110 | + let revoked = true; |
| 111 | + let refreshRejections = 0; |
| 112 | + const server = createServer((request, response) => { |
| 113 | + const chunks: Buffer[] = []; |
| 114 | + request.on("data", (chunk: Buffer) => chunks.push(chunk)); |
| 115 | + request.on("end", () => { |
| 116 | + const body = Buffer.concat(chunks).toString("utf8"); |
| 117 | + if (revoked && new URLSearchParams(body).get("grant_type") === "refresh_token") { |
| 118 | + refreshRejections += 1; |
| 119 | + response.writeHead(400, { "content-type": "application/json" }); |
| 120 | + response.end( |
| 121 | + JSON.stringify({ error: "invalid_grant", error_description: "Grant not found" }), |
| 122 | + ); |
| 123 | + return; |
| 124 | + } |
| 125 | + fetch(upstreamTokenUrl, { |
| 126 | + method: "POST", |
| 127 | + headers: { |
| 128 | + "content-type": |
| 129 | + request.headers["content-type"] ?? "application/x-www-form-urlencoded", |
| 130 | + ...(request.headers.authorization |
| 131 | + ? { authorization: request.headers.authorization } |
| 132 | + : {}), |
| 133 | + }, |
| 134 | + body, |
| 135 | + }) |
| 136 | + .then(async (upstream) => { |
| 137 | + const text = await upstream.text(); |
| 138 | + if (revoked && upstream.ok) { |
| 139 | + const parsed = JSON.parse(text) as Record<string, unknown>; |
| 140 | + parsed["expires_in"] = 0; |
| 141 | + response.writeHead(upstream.status, { "content-type": "application/json" }); |
| 142 | + response.end(JSON.stringify(parsed)); |
| 143 | + return; |
| 144 | + } |
| 145 | + response.writeHead(upstream.status, { |
| 146 | + "content-type": upstream.headers.get("content-type") ?? "application/json", |
| 147 | + }); |
| 148 | + response.end(text); |
| 149 | + }) |
| 150 | + .catch(() => { |
| 151 | + response.writeHead(502, { "content-type": "application/json" }); |
| 152 | + response.end(JSON.stringify({ error: "bad_gateway" })); |
| 153 | + }); |
| 154 | + }); |
| 155 | + }); |
| 156 | + server.listen(0, "127.0.0.1", () => { |
| 157 | + const address = server.address(); |
| 158 | + const port = typeof address === "object" && address !== null ? address.port : 0; |
| 159 | + resume( |
| 160 | + Effect.succeed({ |
| 161 | + tokenUrl: `http://127.0.0.1:${port}/token`, |
| 162 | + restore: () => { |
| 163 | + revoked = false; |
| 164 | + }, |
| 165 | + refreshRejections: () => refreshRejections, |
| 166 | + close: () => { |
| 167 | + server.close(); |
| 168 | + server.closeAllConnections(); |
| 169 | + }, |
| 170 | + }), |
| 171 | + ); |
| 172 | + }); |
| 173 | + }), |
| 174 | + (gate) => Effect.sync(gate.close), |
| 175 | + ); |
| 176 | + |
| 177 | +const seedDcrMcpOAuthConnection = ( |
| 178 | + client: Client, |
| 179 | + prefix: string, |
| 180 | + oauth: OAuthTestServerShape, |
| 181 | + options?: { readonly tokenUrl?: string }, |
| 182 | +) => |
69 | 183 | Effect.gen(function* () { |
70 | | - const oauth = yield* serveOAuthTestServer({ |
71 | | - scopes: ["channels:history", "users:read"], |
72 | | - supportRefresh: false, |
73 | | - tokenExpiresInSeconds: 0, |
74 | | - invalidRefreshTokenDescription: "Grant not found", |
75 | | - }); |
76 | 184 | const slug = IntegrationSlug.make(freshSlug(prefix)); |
77 | 185 | const clientSlug = OAuthClientSlug.make(freshSlug(`${prefix}-client`)); |
78 | 186 |
|
@@ -101,7 +209,7 @@ const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) => |
101 | 209 | issuer: probe.issuer ?? null, |
102 | 210 | registrationEndpoint: probe.registrationEndpoint, |
103 | 211 | authorizationUrl: probe.authorizationUrl, |
104 | | - tokenUrl: probe.tokenUrl, |
| 212 | + tokenUrl: options?.tokenUrl ?? probe.tokenUrl, |
105 | 213 | resource: probe.resource ?? oauth.mcpResourceUrl, |
106 | 214 | scopes: probe.scopesSupported ?? [], |
107 | 215 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, |
@@ -140,6 +248,12 @@ const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) => |
140 | 248 | return { oauth, slug }; |
141 | 249 | }); |
142 | 250 |
|
| 251 | +const seedExpiredDcrMcpOAuthConnection = (client: Client, prefix: string) => |
| 252 | + Effect.gen(function* () { |
| 253 | + const oauth = yield* serveDeadGrantOAuthServer(); |
| 254 | + return yield* seedDcrMcpOAuthConnection(client, prefix, oauth); |
| 255 | + }); |
| 256 | + |
143 | 257 | const logTokenRequests = (label: string, oauth: OAuthTestServerShape) => |
144 | 258 | Effect.gen(function* () { |
145 | 259 | const requests = yield* oauth.requests; |
@@ -224,6 +338,94 @@ scenario( |
224 | 338 | ), |
225 | 339 | ); |
226 | 340 |
|
| 341 | +// The reconnect journey from the bug report: a connection reads Expired, the |
| 342 | +// user completes Reconnect through the OAuth popup, and the page must show the |
| 343 | +// recovered health WITHOUT a hard refresh. The gate makes the provider's |
| 344 | +// grants dead during seeding (already-expired tokens, refresh rejected) and |
| 345 | +// healthy again before the reconnect, so the only thing standing between the |
| 346 | +// user and a green dot is the UI updating itself. |
| 347 | +scenario( |
| 348 | + "MCP OAuth · completed reconnect refreshes the health verdict without a page reload", |
| 349 | + { |
| 350 | + timeout: 240_000, |
| 351 | + }, |
| 352 | + Effect.scoped( |
| 353 | + Effect.gen(function* () { |
| 354 | + const target = yield* Target; |
| 355 | + const browser = yield* Browser; |
| 356 | + const { client: makeApiClient } = yield* Api; |
| 357 | + const identity = yield* target.newIdentity(); |
| 358 | + const client = yield* makeApiClient(api, identity); |
| 359 | + |
| 360 | + const oauth = yield* serveOAuthTestServer({ |
| 361 | + scopes: ["channels:history", "users:read"], |
| 362 | + }); |
| 363 | + const gate = yield* serveGrantRevocationGate(`${oauth.issuerUrl}/token`); |
| 364 | + const { slug } = yield* seedDcrMcpOAuthConnection(client, "mcp-reconnect-live", oauth, { |
| 365 | + tokenUrl: gate.tokenUrl, |
| 366 | + }); |
| 367 | + |
| 368 | + // Persist the expired verdict exactly as the user's "Check now" would. |
| 369 | + const seededHealth = yield* client.connections.checkHealth({ |
| 370 | + params: { owner: "org", integration: slug, name }, |
| 371 | + query: {}, |
| 372 | + }); |
| 373 | + expect(seededHealth.status, "the dead grant seeds an expired verdict").toBe("expired"); |
| 374 | + expect(gate.refreshRejections(), "the expiry came from a rejected refresh").toBeGreaterThan( |
| 375 | + 0, |
| 376 | + ); |
| 377 | + |
| 378 | + yield* browser.session(identity, async ({ page, step }) => { |
| 379 | + const connections = connectionsSection(page); |
| 380 | + const menuTrigger = connections.locator('button[aria-haspopup="menu"]').first(); |
| 381 | + |
| 382 | + await step("Open the integration: the connection reads Expired", async () => { |
| 383 | + await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" }); |
| 384 | + await connections.getByText("main", { exact: true }).waitFor({ timeout: 30_000 }); |
| 385 | + await connections.getByLabel("Status: Expired").waitFor({ timeout: 30_000 }); |
| 386 | + }); |
| 387 | + |
| 388 | + await step("Reconnect and complete the OAuth flow in the popup", async () => { |
| 389 | + // The provider comes back before the user reconnects — fresh grants |
| 390 | + // are fully healthy from here on. |
| 391 | + gate.restore(); |
| 392 | + |
| 393 | + const popupPromise = page.waitForEvent("popup", { timeout: 30_000 }); |
| 394 | + await menuTrigger.click(); |
| 395 | + await page.getByRole("menuitem", { name: "Reconnect" }).click(); |
| 396 | + const popup = await popupPromise; |
| 397 | + |
| 398 | + // The test AS login page is plain text driven by Basic-auth POST, so |
| 399 | + // complete it out of band and drive the popup to the callback — the |
| 400 | + // same journey a user's click-through consent takes. |
| 401 | + await popup.waitForURL(/\/login\?/, { timeout: 30_000 }); |
| 402 | + const callbackUrl = await submitProviderLogin(popup.url()); |
| 403 | + await popup.goto(callbackUrl); |
| 404 | + await page.getByText("Reconnected", { exact: true }).waitFor({ timeout: 30_000 }); |
| 405 | + }); |
| 406 | + |
| 407 | + await step("The backend already sees the connection as healthy", async () => { |
| 408 | + // Evidence that only the UI is stale: the same health endpoint the |
| 409 | + // page would call classifies the re-minted grant as healthy. |
| 410 | + const response = await page.request.post(healthPath(slug)); |
| 411 | + const body = (await response.json()) as { readonly status?: string }; |
| 412 | + console.info(`[BUG repro] post-reconnect health: ${response.status()} ${body.status}`); |
| 413 | + expect(response.status(), "post-reconnect health check succeeds").toBe(200); |
| 414 | + expect(body.status, "the re-minted grant is healthy").toBe("healthy"); |
| 415 | + }); |
| 416 | + |
| 417 | + await step("BUG: the row must flip to Healthy without a hard page refresh", async () => { |
| 418 | + await connections.getByLabel("Status: Healthy").waitFor({ timeout: 30_000 }); |
| 419 | + await connections.getByText("Expired", { exact: true }).waitFor({ |
| 420 | + state: "hidden", |
| 421 | + timeout: 5_000, |
| 422 | + }); |
| 423 | + }); |
| 424 | + }); |
| 425 | + }), |
| 426 | + ), |
| 427 | +); |
| 428 | + |
227 | 429 | scenario( |
228 | 430 | "MCP OAuth · DCR reconnect keeps the dialog open and reaches OAuth start", |
229 | 431 | { |
|
0 commit comments