mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(discord): route activity auth through provider
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user