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 <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-09-21 20:50:01 -07:00
1 parent 1f3ff75d33
commit c496acb570
3 files changed
+21 -2

No files matched your search

+4
View File
@@ -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;
@@ -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()
+2
View File
@@ -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;