diff --git a/extensions/discord/src/activities/discord-api.test.ts b/extensions/discord/src/activities/discord-api.test.ts index ce7badeb95dc..94c934a94516 100644 --- a/extensions/discord/src/activities/discord-api.test.ts +++ b/extensions/discord/src/activities/discord-api.test.ts @@ -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(); diff --git a/extensions/discord/src/activities/discord-api.ts b/extensions/discord/src/activities/discord-api.ts index 316579d6d442..ac0ac9c7b701 100644 --- a/extensions/discord/src/activities/discord-api.ts +++ b/extensions/discord/src/activities/discord-api.ts @@ -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 }> { + 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 { let result: Awaited>; 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; } diff --git a/extensions/discord/src/activities/http.test.ts b/extensions/discord/src/activities/http.test.ts index 3a1cfbf0ba07..b532bc83d521 100644 --- a/extensions/discord/src/activities/http.test.ts +++ b/extensions/discord/src/activities/http.test.ts @@ -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((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(); + } + }); +}); diff --git a/extensions/discord/src/activities/http.ts b/extensions/discord/src/activities/http.ts index ea3bea76c130..fea399723041 100644 --- a/extensions/discord/src/activities/http.ts +++ b/extensions/discord/src/activities/http.ts @@ -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" });