From c496acb5703242cacfcadf4bd60448a38f0d81df Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 21 Sep 2026 20:50:01 -0700 Subject: [PATCH] Return client errors for known OAuth reconnect states (#13794) Map missing OAuth refresh credentials and terminal reauthorization to HTTP 422. Preserve reconnect instructions and reporting of unexpected provider failures. All 363 focused tests and server typecheck pass. Required CI and review checks passed; Greptile 5/5 with no unresolved comments. Co-Authored-By: Paperclip --- doc/observability.md | 4 ++++ .../src/__tests__/tool-access-service.test.ts | 17 +++++++++++++++-- server/src/services/tool-access.ts | 2 ++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/doc/observability.md b/doc/observability.md index ac5a5d22924..9664f99c24d 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -483,6 +483,10 @@ These fields contain build identifiers; they add no tenant or user identity. refreshing its catalog returns 422 with `oauth_challenge` and the existing setup/reconnect links. Other upstream failures still return 502 and are reported, including an unexplained upstream HTTP 400. +- Expired OAuth credentials without a refresh token, or a rejected refresh token + that requires reauthorization. Discovery and health checks return 422 with + `oauth_refresh_missing` or `oauth_reauthorization_required` and the existing + reconnect instructions. Unexpected refresh failures remain reportable. - Slack's explicit response that its app has not enabled MCP access. Discovery and health checks return 422 with `slack_mcp_access_disabled` and setup instructions. This requires Slack's exact MCP endpoint and known error; diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 7ec03d1c13b..bd7a2c6d5fa 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -11973,6 +11973,7 @@ describeEmbeddedPostgres("tool access service", () => { details: expect.objectContaining({ code: "oauth_reauthorization_required", }), + status: 422, }, ); } @@ -12245,7 +12246,7 @@ describeEmbeddedPostgres("tool access service", () => { expect(JSON.stringify(updated.config)).not.toContain("m2m-access-token"); }); - it("fails expired OAuth credentials without a refresh token and returns reconnect links", async () => { + it.each(["catalog", "catalog/refresh", "health-check"])("returns reconnect instructions on %s for expired OAuth without a refresh token", async (path) => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv( "PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", @@ -12328,7 +12329,7 @@ describeEmbeddedPostgres("tool access service", () => { actorId: "board", }), ).rejects.toMatchObject({ - status: 502, + status: 422, details: expect.objectContaining({ code: "oauth_refresh_missing", setupUrl: `/apps/${connect.connectionId}/permissions`, @@ -12336,6 +12337,18 @@ describeEmbeddedPostgres("tool access service", () => { connection: expect.objectContaining({ healthStatus: "failed" }), }), }); + await db.delete(toolCatalogEntries).where(eq(toolCatalogEntries.connectionId, connect.connectionId)); + const capture = vi.spyOn(sentry, "captureException").mockImplementation(() => {}); + const app = createRouteApp(db, boardSessionActor(company.id, "owner", "board")); + const url = `/api/tool-connections/${connect.connectionId}/${path}`; + const response = await (path === "catalog" ? request(app).get(url) : request(app).post(url)); + expect(response.status).toBe(422); + expect(response.body).toMatchObject({ + code: "oauth_refresh_missing", + error: "OAuth credentials have expired and need to be reconnected.", + details: { setupUrl: `/apps/${connect.connectionId}/permissions`, reconnectUrl: `/apps/${connect.connectionId}/permissions` }, + }); + expect(capture).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled(); const auditRows = await db .select() diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 3004d380b17..c4a47bd6d3b 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -2805,6 +2805,8 @@ function healthFailureHttpStatus(failure: { }): number { if (failure.status === "missing_secret") return 422; if (failure.code === "oauth_challenge") return 422; + if (failure.code === "oauth_refresh_missing") return 422; + if (failure.code === "oauth_reauthorization_required") return 422; if (failure.code === "slack_mcp_access_disabled") return 422; if (failure.code === "user_authorization_required") return 422; if (failure.code === "composio_broker_retired") return 422;