fix: preserve API error contracts for HTTP admission denials (#644)

Return HTTP admission denials through the existing OAuth connection-request failure callback and preserve console versus runtime error response formats, including retry hints.

Add seven regression cases for token-exchange and credential-validation denials, callbacks with and without return URIs, request state, blocked egress, unsaved credentials, callback replay, and route-specific envelopes.

Validation: 222 targeted tests, fix-check, GitHub CI, and package build/installation/startup/Bun verification passed.
This commit is contained in:
l1shen
2026-10-02 20:55:52 +08:00
committed by GitHub
parent 0ac7fd9f9d
commit 0535653ace
3 changed files with 138 additions and 8 deletions
+8 -2
View File
@@ -396,8 +396,14 @@ export class OAuthFlowService {
};
} catch (error) {
if (error instanceof ProviderDispatchRequestError) {
if (request) await this.requests.fail(request.connectionRequestId, "rate_limited", error.message);
throw error;
if (!request) throw error;
await this.requests.fail(request.connectionRequestId, "rate_limited", error.message);
throw new OAuthCallbackError(
"rate_limited",
error.message,
callbackReturnUri(request, "error", "rate_limited", error.message),
error,
);
}
if (request) {
const code =
+10 -6
View File
@@ -420,12 +420,16 @@ export class ConnectServer {
else app.notFound(notFound);
app.onError((error, context) => {
if (error instanceof ProviderDispatchRequestError) {
return writeRuntimeFailure(context, {
status: 429,
errorCode: "rate_limited",
message: error.message,
data: toProviderExecutionError(error, error.message).error?.details,
});
if (context.req.path.startsWith("/v1/"))
return writeRuntimeFailure(context, {
status: 429,
errorCode: "rate_limited",
message: error.message,
data: toProviderExecutionError(error, error.message).error?.details,
});
const seconds = optionalRecord(error.details)?.retryAfterSeconds;
if (typeof seconds === "number") context.header("Retry-After", String(seconds));
return jsonError(context, 429, "rate_limited", error.message);
}
if (error instanceof SaasError) {
this.options.logger?.warn(
+120
View File
@@ -21,6 +21,126 @@ afterEach(async () => {
});
describe("headless runtime", () => {
it.each([45, undefined])(
"preserves console and runtime denial formats with retry delay %s",
async (retryAfterSeconds) => {
const fetcher = vi.fn<typeof fetch>();
vi.stubGlobal("fetch", fetcher);
runtime = await createConnectorRuntime({
...(await fixture()),
providerHttpDispatch: { beforeAttempt: () => ({ allow: false, retryAfterSeconds }) },
});
const consoleResponse = await request(
"/api/connections/github",
{ authType: "api_key", values: { apiKey: "fixture-secret" } },
"admin-token",
"PUT",
);
const runtimeResponse = await request("/v1/connections/github/connect/api-key", { apiKey: "fixture-secret" });
for (const response of [consoleResponse, runtimeResponse]) {
expect(response.status).toBe(429);
expect(response.headers.get("Retry-After")).toBe(
retryAfterSeconds === undefined ? null : String(retryAfterSeconds),
);
}
expect(await consoleResponse.json()).toEqual({
error: { code: "rate_limited", message: "Provider HTTP dispatch is temporarily unavailable." },
});
expect(await runtimeResponse.json()).toMatchObject({
success: false,
errorCode: "rate_limited",
message: "Provider HTTP dispatch is temporarily unavailable.",
});
expect(fetcher).not.toHaveBeenCalled();
expect((await (await request("/v1/connections")).json()).data).toEqual([]);
},
);
it.each([
["oauth", "https://host.example/settings/connections"],
["credential_validation", "https://host.example/settings/connections"],
["oauth", undefined],
["credential_validation", undefined],
])("completes a denied %s connection request with return URI %s", async (operation, returnUri) => {
const fetcher = vi.fn<typeof fetch>(async () =>
Response.json({ access_token: "fixture-access-token", token_type: "bearer", scope: "read:user" }),
);
vi.stubGlobal("fetch", fetcher);
runtime = await createConnectorRuntime({
...(await fixture()),
providerHttpDispatch: {
beforeAttempt: (attempt) =>
attempt.context.operation === operation ? { allow: false, retryAfterSeconds: 45 } : { allow: true },
},
});
expect(
(
await request(
"/api/oauth/configs/github",
{ clientId: "fixture-client", clientSecret: "fixture-secret" },
"admin-token",
"PUT",
)
).status,
).toBe(200);
const started = await request("/v1/connections/github/connect", { returnUri });
expect(started.status).toBe(200);
const attempt = (await started.json()).data;
const state = new URL(attempt.authorizationUrl).searchParams.get("state")!;
const path = `/oauth/callback?state=${encodeURIComponent(state)}&code=fixture-code`;
const callback = await request(path);
if (returnUri) {
expect(callback.status).toBe(302);
const returned = new URL(callback.headers.get("location")!);
expect(`${returned.origin}${returned.pathname}`).toBe(returnUri);
expect(Object.fromEntries(returned.searchParams)).toEqual({
status: "error",
service: "github",
code: "rate_limited",
message: "Provider HTTP dispatch is temporarily unavailable.",
});
} else {
expect(callback.status).toBe(400);
expect(await callback.json()).toEqual({
error: { code: "rate_limited", message: "Provider HTTP dispatch is temporarily unavailable." },
});
}
expect(callback.headers.get("Retry-After")).toBeNull();
const completed = await request(`/v1/connection-requests/${attempt.connectionRequestId}`);
expect((await completed.json()).data).toMatchObject({ status: "failed", errorCode: "rate_limited" });
expect((await (await request("/v1/connections")).json()).data).toEqual([]);
expect(fetcher).toHaveBeenCalledTimes(operation === "oauth" ? 0 : 1);
const replay = await request(path);
expect(replay.status).toBe(400);
expect(await replay.json()).toMatchObject({ error: { code: "invalid_oauth_state" } });
});
it("preserves dispatch denial for OAuth authorizations without a connection request", async () => {
const fetcher = vi.fn<typeof fetch>();
vi.stubGlobal("fetch", fetcher);
runtime = await createConnectorRuntime({
...(await fixture()),
providerHttpDispatch: { beforeAttempt: () => ({ allow: false, retryAfterSeconds: 45 }) },
});
await request(
"/api/oauth/configs/github",
{ clientId: "fixture-client", clientSecret: "fixture-secret" },
"admin-token",
"PUT",
);
const started = await request("/api/oauth/authorizations", { service: "github" });
expect(started.status).toBe(200);
const { state } = await started.json();
const callback = await request(`/oauth/callback?state=${encodeURIComponent(state)}&code=fixture-code`);
expect(callback.status).toBe(429);
expect(callback.headers.get("Retry-After")).toBe("45");
expect(await callback.json()).toEqual({
error: { code: "rate_limited", message: "Provider HTTP dispatch is temporarily unavailable." },
});
expect(fetcher).not.toHaveBeenCalled();
expect((await (await request("/v1/connections")).json()).data).toEqual([]);
});
it("preserves admission denial after a real provider remaps it into validation and transport errors", async () => {
let deny = false;
const fetcher = vi.fn<typeof fetch>(async () => Response.json({ credit_balance: 100 }));