From ddefea9c87309b9edad04a0c0e3d9f13f6ce2f87 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Mon, 18 May 2026 15:46:49 +0200 Subject: [PATCH 1/2] fix(auth): auto-refresh on PostHog 401/403 auth failures PostHog's OAuth access tokens can go dead before the client's local `accessTokenExpiresAt` expires (the previous access token is invalidated when a new one is minted via refresh). When that happens, callers see HTTP 401/403 with bodies the proxy and renderer fetcher weren't matching, so neither auto-recovered. - MCP proxy: retry-with-refresh on HTTP 401 in addition to the existing JSON-RPC body sentinels. Extend the sentinel set to match the literal strings the servers actually return (`Invalid API key` from mcp.posthog.com via Cloudflare, `Authentication failed` from the us.posthog.com installation proxy). - Renderer fetcher: retry-with-refresh on HTTP 403 when the body is `{type: "authentication_error", code: "authentication_failed"}` (the shape PostHog's Django API returns for invalid bearer tokens), in addition to the existing 401 retry. Generated-By: PostHog Code Task-Id: 83ede0c1-3f83-4fe3-a4bd-69cf379be315 --- .../code/src/main/services/mcp-proxy/service.ts | 12 +++++++++--- apps/code/src/renderer/api/fetcher.ts | 17 +++++++++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/code/src/main/services/mcp-proxy/service.ts b/apps/code/src/main/services/mcp-proxy/service.ts index 07ffd9241..9d0243fe2 100644 --- a/apps/code/src/main/services/mcp-proxy/service.ts +++ b/apps/code/src/main/services/mcp-proxy/service.ts @@ -183,10 +183,14 @@ export class McpProxyService { const buf = Buffer.from(await response.arrayBuffer()); const bodyText = buf.toString("utf8"); - if (this.isAuthErrorBody(bodyText)) { - log.warn("MCP auth error in body — refreshing token and retrying", { + const isAuthFailure = + response.status === 401 || this.isAuthErrorBody(bodyText); + + if (isAuthFailure) { + log.warn("MCP auth failure — refreshing token and retrying", { id, url, + status: response.status, }); await this.authService.refreshAccessToken(); response = await this.authService.authenticatedFetch( @@ -238,7 +242,9 @@ export class McpProxyService { private isAuthErrorBody(bodyText: string): boolean { return ( bodyText.includes('"authentication_failed"') || - bodyText.includes('"authentication_error"') + bodyText.includes('"authentication_error"') || + bodyText.includes("Invalid API key") || + bodyText.includes("Authentication failed") ); } diff --git a/apps/code/src/renderer/api/fetcher.ts b/apps/code/src/renderer/api/fetcher.ts index 74a45e26d..2978f0b1c 100644 --- a/apps/code/src/renderer/api/fetcher.ts +++ b/apps/code/src/renderer/api/fetcher.ts @@ -52,12 +52,25 @@ export const buildApiFetcher: (config: { } }; + const isAuthFailure = async (response: Response): Promise => { + if (response.status === 401) return true; + if (response.status !== 403) return false; + try { + const body = await response.clone().json(); + return ( + body?.code === "authentication_failed" || + body?.type === "authentication_error" + ); + } catch { + return false; + } + }; + return { fetch: async (input) => { let response = await makeRequest(input, await config.getAccessToken()); - // Retry once on 401 after asking main for a fresh valid token again. - if (!response.ok && response.status === 401) { + if (!response.ok && (await isAuthFailure(response))) { try { response = await makeRequest( input, From b2e7a3321463d555eb12778f38f94fcd8f9483bc Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Mon, 18 May 2026 16:06:01 +0200 Subject: [PATCH 2/2] fix(auth): address Greptile review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop bare HTTP 401 trigger from MCP proxy retry — authenticatedFetch already retries on 401/403, so the second refresh was redundant and risked churning tokens under the server's in-place access-token rotation behavior. - Gate the new substring matches (`Invalid API key`, `Authentication failed`) on `status >= 400` to avoid spurious refreshes when a 200 JSON-RPC tool result legitimately mentions those phrases. - Add tests covering the renderer fetcher's 403-with-authentication_failed retry path and adjacent negative cases. Generated-By: PostHog Code Task-Id: 83ede0c1-3f83-4fe3-a4bd-69cf379be315 --- .../src/main/services/mcp-proxy/service.ts | 16 ++++--- apps/code/src/renderer/api/fetcher.test.ts | 46 +++++++++++++++++-- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/apps/code/src/main/services/mcp-proxy/service.ts b/apps/code/src/main/services/mcp-proxy/service.ts index 9d0243fe2..1cf267355 100644 --- a/apps/code/src/main/services/mcp-proxy/service.ts +++ b/apps/code/src/main/services/mcp-proxy/service.ts @@ -183,10 +183,7 @@ export class McpProxyService { const buf = Buffer.from(await response.arrayBuffer()); const bodyText = buf.toString("utf8"); - const isAuthFailure = - response.status === 401 || this.isAuthErrorBody(bodyText); - - if (isAuthFailure) { + if (this.isAuthErrorBody(bodyText, response.status)) { log.warn("MCP auth failure — refreshing token and retrying", { id, url, @@ -239,10 +236,15 @@ export class McpProxyService { } } - private isAuthErrorBody(bodyText: string): boolean { - return ( + private isAuthErrorBody(bodyText: string, status: number): boolean { + if ( bodyText.includes('"authentication_failed"') || - bodyText.includes('"authentication_error"') || + bodyText.includes('"authentication_error"') + ) { + return true; + } + if (status < 400) return false; + return ( bodyText.includes("Invalid API key") || bodyText.includes("Authentication failed") ); diff --git a/apps/code/src/renderer/api/fetcher.test.ts b/apps/code/src/renderer/api/fetcher.test.ts index 62a510620..b74b32409 100644 --- a/apps/code/src/renderer/api/fetcher.test.ts +++ b/apps/code/src/renderer/api/fetcher.test.ts @@ -13,15 +13,15 @@ describe("buildApiFetcher", () => { status: 200, json: () => Promise.resolve(data), }); - const err = (status: number) => { + const err = (status: number, body: object = { error: status }) => { const response = { ok: false, status, statusText: `Error ${status}`, - json: () => Promise.resolve({ error: status }), + json: () => Promise.resolve(body), clone: () => ({ ...response, - text: () => Promise.resolve(`Error ${status}`), + text: () => Promise.resolve(JSON.stringify(body)), }), }; return response; @@ -63,10 +63,10 @@ describe("buildApiFetcher", () => { ); }); - it("does not retry on non-401 errors", async () => { + it("does not retry on 403 without authentication_failed body", async () => { const getAccessToken = vi.fn().mockResolvedValue("token"); const refreshAccessToken = vi.fn().mockResolvedValue("new-token"); - mockFetch.mockResolvedValueOnce(err(403)); + mockFetch.mockResolvedValueOnce(err(403, { detail: "Permission denied." })); const fetcher = buildApiFetcher({ getAccessToken, refreshAccessToken }); @@ -75,6 +75,42 @@ describe("buildApiFetcher", () => { expect(refreshAccessToken).not.toHaveBeenCalled(); }); + it("retries with a fresh token on 403 with authentication_failed body", async () => { + const getAccessToken = vi.fn().mockResolvedValue("stale-token"); + const refreshAccessToken = vi.fn().mockResolvedValue("fresh-token"); + mockFetch + .mockResolvedValueOnce( + err(403, { + type: "authentication_error", + code: "authentication_failed", + detail: "Invalid access token.", + }), + ) + .mockResolvedValueOnce(ok()); + + const fetcher = buildApiFetcher({ getAccessToken, refreshAccessToken }); + const response = await fetcher.fetch(mockInput); + + expect(response.ok).toBe(true); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[1][1].headers.get("Authorization")).toBe( + "Bearer fresh-token", + ); + }); + + it("does not retry on other 4xx errors", async () => { + const refreshAccessToken = vi.fn().mockResolvedValue("new-token"); + mockFetch.mockResolvedValueOnce(err(400, { detail: "Bad request." })); + + const fetcher = buildApiFetcher({ + getAccessToken: vi.fn().mockResolvedValue("token"), + refreshAccessToken, + }); + + await expect(fetcher.fetch(mockInput)).rejects.toThrow("[400]"); + expect(refreshAccessToken).not.toHaveBeenCalled(); + }); + it("throws when the retry still returns 401", async () => { const getAccessToken = vi.fn().mockResolvedValue("token-1"); const refreshAccessToken = vi.fn().mockResolvedValue("token-2");