fix(discord): route activity auth through provider

This commit is contained in:
Dallin Romney
2026-08-10 20:42:26 +08:00
parent 8c218fe251
commit 54b0d12569
4 changed files with 173 additions and 42 deletions
@@ -12,6 +12,7 @@ import { initializeDiscordProviderEndpointForTest } from "../provider-endpoint.t
let fetchDiscordJson: typeof import("./discord-api.js").fetchDiscordJson;
let resolveActivityInstanceChannel: typeof import("./discord-api.js").resolveActivityInstanceChannel;
let userRoute: typeof import("./discord-api.js").DISCORD_ACTIVITY_USER_ROUTE;
const TEST_DESCRIPTOR = {
restApiBaseUrl: "http://127.0.0.1:43123/custom/rest/v10",
@@ -23,7 +24,11 @@ describe("Discord Activity API", () => {
beforeEach(async () => {
vi.resetModules();
providerFetchGuardMock.mockReset();
({ fetchDiscordJson, resolveActivityInstanceChannel } = await import("./discord-api.js"));
({
DISCORD_ACTIVITY_USER_ROUTE: userRoute,
fetchDiscordJson,
resolveActivityInstanceChannel,
} = await import("./discord-api.js"));
});
afterEach(() => {
@@ -53,9 +58,8 @@ describe("Discord Activity API", () => {
await expect(
fetchDiscordJson({
fetchGuard,
url: "https://discord.com/api/v10/users/@me",
route: userRoute,
init: { headers: { Authorization: "Bearer test-token" } },
auditContext: "discord.activities.oauth.user",
}),
).resolves.toEqual({ ok: false, status: 401 });
expect(lifecycle).toEqual(["cancel", "release"]);
@@ -79,9 +83,8 @@ describe("Discord Activity API", () => {
await expect(
fetchDiscordJson({
fetchGuard,
url: "https://discord.com/api/v10/users/@me",
route: userRoute,
init: { headers: { Authorization: "Bearer test-token" } },
auditContext: "discord.activities.oauth.user",
}),
).resolves.toEqual({ ok: false, status: 429 });
expect(release).toHaveBeenCalledOnce();
@@ -4,13 +4,35 @@ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { DISCORD_DEFAULT_REST_API_BASE_URL } from "../provider-endpoint.constants.js";
import { getDiscordProviderEndpointRuntime } from "../provider-endpoint.js";
export const DISCORD_TOKEN_URL = "https://discord.com/api/oauth2/token";
export const DISCORD_USER_URL = `${DISCORD_DEFAULT_REST_API_BASE_URL}/users/@me`;
const DISCORD_LIVE_OAUTH_API_BASE_URL = "https://discord.com/api";
const DISCORD_TOKEN_URL = `${DISCORD_LIVE_OAUTH_API_BASE_URL}${Routes.oauth2TokenExchange()}`;
const DISCORD_USER_URL = `${DISCORD_DEFAULT_REST_API_BASE_URL}${Routes.user()}`;
const DISCORD_HOST = "discord.com";
const DISCORD_ACTIVITY_API_TIMEOUT_MS = 15_000;
const JSON_MAX_BYTES = 64 * 1024;
const INSTANCE_ID_MAX_LENGTH = 256;
type DiscordActivityJsonRoute = Readonly<{
providerPath: string;
liveUrl: string;
auditContext: string;
responseLabel: string;
}>;
export const DISCORD_ACTIVITY_TOKEN_ROUTE: DiscordActivityJsonRoute = {
providerPath: Routes.oauth2TokenExchange(),
liveUrl: DISCORD_TOKEN_URL,
auditContext: "discord.activities.oauth.token",
responseLabel: "Discord Activity OAuth",
};
export const DISCORD_ACTIVITY_USER_ROUTE: DiscordActivityJsonRoute = {
providerPath: Routes.user(),
liveUrl: DISCORD_USER_URL,
auditContext: "discord.activities.oauth.user",
responseLabel: "Discord Activity OAuth",
};
export { fetchWithSsrFGuard };
export type FetchGuard = typeof fetchWithSsrFGuard;
@@ -50,20 +72,38 @@ export function normalizeInstanceId(value: string | null): string | undefined {
export async function fetchDiscordJson(params: {
fetchGuard: FetchGuard;
fetchImpl?: typeof fetch;
url: string;
route: DiscordActivityJsonRoute;
init: RequestInit;
auditContext: string;
}): Promise<{ ok: boolean; status: number; body?: Record<string, unknown> }> {
const providerEndpoint = getDiscordProviderEndpointRuntime();
if (providerEndpoint) {
const timeoutController = new AbortController();
const timeout = setTimeout(() => timeoutController.abort(), DISCORD_ACTIVITY_API_TIMEOUT_MS);
timeout.unref?.();
const signal = params.init.signal
? AbortSignal.any([params.init.signal, timeoutController.signal])
: timeoutController.signal;
try {
const response = await providerEndpoint.fetch(
`${providerEndpoint.descriptor.restApiBaseUrl}${params.route.providerPath}`,
{ ...params.init, signal },
);
return await readDiscordJsonResult(response, params.route.responseLabel);
} finally {
clearTimeout(timeout);
}
}
const { response, release } = await params.fetchGuard({
url: params.url,
url: params.route.liveUrl,
fetchImpl: params.fetchImpl,
init: params.init,
policy: { allowedHostnames: [DISCORD_HOST] },
auditContext: params.auditContext,
auditContext: params.route.auditContext,
timeoutMs: DISCORD_ACTIVITY_API_TIMEOUT_MS,
});
try {
return await readDiscordJsonResult(response, "Discord Activity OAuth");
return await readDiscordJsonResult(response, params.route.responseLabel);
} finally {
await release();
}
@@ -79,34 +119,21 @@ export async function resolveActivityInstanceChannel(params: {
}): Promise<string | undefined> {
let result: Awaited<ReturnType<typeof fetchDiscordJson>>;
try {
const route = Routes.applicationActivityInstance(
const providerPath = Routes.applicationActivityInstance(
encodeURIComponent(params.applicationId),
encodeURIComponent(params.instanceId),
);
const providerEndpoint = getDiscordProviderEndpointRuntime();
const init = { headers: { Authorization: `Bot ${params.botAuth}` } };
if (providerEndpoint) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DISCORD_ACTIVITY_API_TIMEOUT_MS);
timeout.unref?.();
try {
const response = await providerEndpoint.fetch(
`${providerEndpoint.descriptor.restApiBaseUrl}${route}`,
{ ...init, signal: controller.signal },
);
result = await readDiscordJsonResult(response, "Discord Activity instance");
} finally {
clearTimeout(timeout);
}
} else {
result = await fetchDiscordJson({
fetchGuard: params.fetchGuard,
fetchImpl: params.proxyFetch,
url: `${DISCORD_DEFAULT_REST_API_BASE_URL}${route}`,
init,
result = await fetchDiscordJson({
fetchGuard: params.fetchGuard,
fetchImpl: params.proxyFetch,
route: {
providerPath,
liveUrl: `${DISCORD_DEFAULT_REST_API_BASE_URL}${providerPath}`,
auditContext: "discord.activities.instance",
});
}
responseLabel: "Discord Activity instance",
},
init: { headers: { Authorization: `Bot ${params.botAuth}` } },
});
} catch {
return undefined;
}
@@ -6,6 +6,7 @@ import path from "node:path";
import type { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildDiscordActivityCustomId } from "../component-custom-id.js";
import { initializeDiscordProviderEndpointForTest } from "../provider-endpoint.test-support.js";
import { createDiscordActivityHttpHandler } from "./http.js";
import { DiscordActivitiesRuntime } from "./runtime.js";
import {
@@ -776,3 +777,105 @@ describe("Discord Activity shell assets", () => {
expect(readVendorAsset).toHaveBeenCalledTimes(2);
});
});
describe("Discord Activity provider routing", () => {
it("routes the complete authorization round trip through the provider only", async () => {
const providerRequests: Array<{
url: string;
method: string;
authorization?: string;
contentType?: string;
body: string;
}> = [];
const providerServer = createServer((req, res) => {
void (async () => {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const request = {
url: req.url ?? "",
method: req.method ?? "",
authorization: req.headers.authorization,
contentType: req.headers["content-type"],
body: Buffer.concat(chunks).toString("utf8"),
};
providerRequests.push(request);
const responseBody = request.url.endsWith("/oauth2/token")
? { access_token: "atoken" }
: request.url.endsWith("/users/@me")
? { id: "42", username: "alice", discriminator: "0" }
: request.url.includes("/activity-instances/")
? { location: { channel_id: "777" }, users: ["42"] }
: undefined;
res.statusCode = responseBody ? 200 : 404;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(responseBody ?? { error: "unexpected route" }));
})();
});
servers.push(providerServer);
await new Promise<void>((resolve) => {
providerServer.listen(0, "127.0.0.1", resolve);
});
const providerPort = (providerServer.address() as AddressInfo).port;
await initializeDiscordProviderEndpointForTest({
restApiBaseUrl: `http://127.0.0.1:${providerPort}/custom/rest/v10`,
gatewayBotUrl: `http://127.0.0.1:${providerPort}/custom/gateway-metadata`,
gatewayOrigin: `ws://127.0.0.1:${providerPort}`,
});
const runtime = createActivityTestRuntime();
const widgetId = await createWidget(runtime);
const original = runtime.resolveHttpAccount();
if (!original) {
throw new Error("missing account");
}
const proxyFetch = vi.fn(async () => {
throw new Error("provider mode must not call the account proxy");
});
const account = { ...original, proxyFetch: proxyFetch as unknown as typeof fetch };
vi.spyOn(runtime, "resolveHttpAccount").mockReturnValue(account);
vi.spyOn(runtime, "resolveAccount").mockReturnValue(account);
const publicFetchGuard = vi.fn(async () => {
throw new Error("provider mode must not call the public Discord guard");
}) as unknown as typeof fetchWithSsrFGuard;
const base = await startServer(runtime, { fetchGuard: publicFetchGuard });
try {
const tokenResponse = await requestToken(base, { code: "oauth-code" });
expect(tokenResponse.status).toBe(200);
const token = (await tokenResponse.json()) as { session_token: string };
const widgetResponse = await requestWidget(
base,
`custom_id=${widgetId}&instance_id=instance-1`,
token.session_token,
);
expect(widgetResponse.status).toBe(200);
expect(providerRequests.map(({ method, url }) => ({ method, url }))).toEqual([
{ method: "POST", url: "/custom/rest/v10/oauth2/token" },
{ method: "GET", url: "/custom/rest/v10/users/@me" },
{
method: "GET",
url: "/custom/rest/v10/applications/123456789012345678/activity-instances/instance-1",
},
]);
expect(providerRequests[0]).toMatchObject({
authorization: undefined,
contentType: "application/x-www-form-urlencoded",
});
expect(Array.from(new URLSearchParams(providerRequests[0]?.body).entries())).toEqual([
["grant_type", "authorization_code"],
["client_id", "123456789012345678"],
["client_secret", "testsec"],
["code", "oauth-code"],
]);
expect(providerRequests[1]?.authorization).toBe("Bearer atoken");
expect(providerRequests[2]?.authorization).toBe("Bot testtok");
expect(publicFetchGuard).not.toHaveBeenCalled();
expect(proxyFetch).not.toHaveBeenCalled();
} finally {
vi.resetModules();
}
});
});
+4 -6
View File
@@ -8,8 +8,8 @@ import {
} from "openclaw/plugin-sdk/webhook-request-guards";
import { parseDiscordActivityCustomId } from "../component-custom-id.js";
import {
DISCORD_TOKEN_URL,
DISCORD_USER_URL,
DISCORD_ACTIVITY_TOKEN_ROUTE,
DISCORD_ACTIVITY_USER_ROUTE,
fetchDiscordJson,
fetchWithSsrFGuard,
type FetchGuard,
@@ -184,7 +184,7 @@ export function createDiscordActivityHttpHandler(deps: DiscordActivityHttpDeps):
tokenResponse = await fetchDiscordJson({
fetchGuard,
fetchImpl: account.proxyFetch,
url: DISCORD_TOKEN_URL,
route: DISCORD_ACTIVITY_TOKEN_ROUTE,
init: {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -195,7 +195,6 @@ export function createDiscordActivityHttpHandler(deps: DiscordActivityHttpDeps):
code,
}),
},
auditContext: "discord.activities.oauth.token",
});
} catch {
return respondJson(res, 503, { error: "Discord token exchange unavailable" });
@@ -212,9 +211,8 @@ export function createDiscordActivityHttpHandler(deps: DiscordActivityHttpDeps):
userResponse = await fetchDiscordJson({
fetchGuard,
fetchImpl: account.proxyFetch,
url: DISCORD_USER_URL,
route: DISCORD_ACTIVITY_USER_ROUTE,
init: { headers: { Authorization: `Bearer ${granted}` } },
auditContext: "discord.activities.oauth.user",
});
} catch {
return respondJson(res, 503, { error: "Discord user lookup unavailable" });