diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index de9b52ae9..cd48cf0c8 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -41,7 +41,7 @@ import { type Executor, type StorageFailure, } from "@executor-js/sdk"; -import { makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal"; +import { makeHostedFetch, makeHostedHttpClientLayer } from "@executor-js/sdk/host-internal"; import { DbProvider } from "./executor-fuma-db"; @@ -241,9 +241,11 @@ export const makeScopedExecutor = < }); const plugins = pluginsFactory(); - const httpClientLayer = makeHostedHttpClientLayer({ + const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork, - }); + }; + const httpClientLayer = makeHostedHttpClientLayer(hostedHttpOptions); + const hostedFetch = makeHostedFetch(hostedHttpOptions); // The org id is the tenant (catalog partition); the account id is the acting // subject (drives `owner: "user"` rows). `organizationName` is no longer part @@ -255,6 +257,7 @@ export const makeScopedExecutor = < blobs, plugins, httpClientLayer, + fetch: hostedFetch, onElicitation: "accept-all", redirectUri, coreTools: { diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index dcd85819e..9e0ad63aa 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -357,6 +357,11 @@ export interface ExecutorConfig; + /** + * Fetch API implementation for dependencies that cannot consume `httpClientLayer`. + * Prefer `httpClientLayer` for normal SDK and plugin HTTP. + */ + readonly fetch?: typeof globalThis.fetch; /** * The OAuth callback URL (`${webBaseUrl}/oauth/callback`) the host serves and * sends to providers. There is NO localhost default: omit it (or pass @@ -1455,6 +1460,7 @@ export const createExecutor = cause.error === "invalid_grant" @@ -3079,6 +3086,7 @@ export const createExecutor = { }), ); + it("applies the DNS guard to fetch callers", async () => { + let calls = 0; + const hostedFetch = makeHostedFetch({ + fetch: (async () => { + calls++; + return new Response("unexpected", { status: 200 }); + }) as typeof globalThis.fetch, + resolveHostname: async () => [{ address: "10.0.0.20", family: 4 }], + }); + + await expect(hostedFetch("https://api.example/token")).rejects.toMatchObject({ + _tag: "HostedOutboundRequestBlocked", + }); + expect(calls).toBe(0); + }); + it.effect("checks redirected URLs before following them", () => Effect.gen(function* () { let calls = 0; diff --git a/packages/core/sdk/src/hosted-http-client.ts b/packages/core/sdk/src/hosted-http-client.ts index 029ec3367..37f1ea427 100644 --- a/packages/core/sdk/src/hosted-http-client.ts +++ b/packages/core/sdk/src/hosted-http-client.ts @@ -246,6 +246,10 @@ const guardFetch = ( return await underlying(current, { ...currentInit, redirect: "manual" }); }) as typeof globalThis.fetch; +export const makeHostedFetch = (options: HostedHttpClientOptions = {}): typeof globalThis.fetch => + // oxlint-disable-next-line executor/no-raw-fetch -- boundary: exposes a guarded Fetch API adapter for libraries that require fetch + guardFetch(options.fetch ?? globalThis.fetch, options); + export const makeHostedHttpClientLayer = ( options: HostedHttpClientOptions = {}, ): Layer.Layer => diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index e047e88d8..3ed7037f5 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -608,6 +608,49 @@ describe("exchangeAuthorizationCode", () => { }); describe("exchangeClientCredentials", () => { + it.effect("routes token grant requests through the injected fetch", () => + withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl }) => + Effect.gen(function* () { + const seen: Array<{ url: string; method: string | undefined }> = []; + const customFetch: typeof globalThis.fetch = (async (input, init) => { + seen.push({ + url: input instanceof Request ? input.url : String(input), + method: init?.method, + }); + // oxlint-disable-next-line executor/no-raw-fetch -- boundary: test fetch adapter delegates to the local token endpoint + return fetch(input, init); + }) as typeof globalThis.fetch; + + yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + fetch: customFetch, + }); + yield* exchangeClientCredentials({ + tokenUrl, + clientId: "cid", + clientSecret: "secret", + fetch: customFetch, + }); + yield* refreshAccessToken({ + tokenUrl, + clientId: "cid", + refreshToken: "old", + fetch: customFetch, + }); + + expect(seen).toEqual([ + { url: tokenUrl, method: "POST" }, + { url: tokenUrl, method: "POST" }, + { url: tokenUrl, method: "POST" }, + ]); + }), + ), + ); + it.effect("rejects unsupported token URL schemes before exchange", () => Effect.gen(function* () { const exit = yield* Effect.exit( diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 9672f0bd2..c619704ff 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -417,10 +417,14 @@ const oauth4webapiRequestOptions = ( targetUrl: string, timeoutMs: number | undefined, endpointUrlPolicy: OAuthEndpointUrlPolicy = {}, + customFetch?: typeof globalThis.fetch, ): Record => { const options: Record = { signal: AbortSignal.timeout(timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS), }; + if (customFetch) { + (options as { [oauth.customFetch]?: typeof globalThis.fetch })[oauth.customFetch] = customFetch; + } if ( isLoopbackHttpUrl(targetUrl) || (URL.canParse(targetUrl) && @@ -510,6 +514,7 @@ export type ExchangeAuthorizationCodeInput = { readonly resource?: string; readonly timeoutMs?: number; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; + readonly fetch?: typeof globalThis.fetch; }; export const exchangeAuthorizationCode = ( @@ -545,7 +550,12 @@ export const exchangeAuthorizationCode = ( clientAuth, "authorization_code", params, - oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy), + oauth4webapiRequestOptions( + input.tokenUrl, + input.timeoutMs, + input.endpointUrlPolicy, + input.fetch, + ), ); return await processTokenEndpointResponse(as, client, response); }, @@ -568,6 +578,7 @@ export type ExchangeClientCredentialsInput = { readonly resource?: string; readonly timeoutMs?: number; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; + readonly fetch?: typeof globalThis.fetch; }; export const exchangeClientCredentials = ( @@ -593,7 +604,12 @@ export const exchangeClientCredentials = ( client, clientAuth, params, - oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy), + oauth4webapiRequestOptions( + input.tokenUrl, + input.timeoutMs, + input.endpointUrlPolicy, + input.fetch, + ), ); const result = await oauth.processClientCredentialsResponse(as, client, response); return tokenResponseFrom(result); @@ -621,6 +637,7 @@ export type RefreshAccessTokenInput = { readonly resource?: string; readonly timeoutMs?: number; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; + readonly fetch?: typeof globalThis.fetch; }; export const refreshAccessToken = ( @@ -652,7 +669,12 @@ export const refreshAccessToken = ( clientAuth, input.refreshToken, { - ...oauth4webapiRequestOptions(input.tokenUrl, input.timeoutMs, input.endpointUrlPolicy), + ...oauth4webapiRequestOptions( + input.tokenUrl, + input.timeoutMs, + input.endpointUrlPolicy, + input.fetch, + ), additionalParameters, }, ); diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index ea850c18b..008719b75 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -132,6 +132,7 @@ export interface OAuthServiceDeps { template: AuthTemplateSlug, ) => Effect.Effect; readonly httpClientLayer?: Layer.Layer; + readonly fetch?: typeof globalThis.fetch; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; /** * The OAuth callback URL (`${webBaseUrl}${mountPrefix}/oauth/callback`) the host @@ -341,6 +342,7 @@ const validateClientEndpoints = ( export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer; + const fetch = deps.fetch; // EXPLICIT — no localhost default. `null` means this executor has no OAuth // callback; redirect-requiring flows fail loudly via `requireRedirectUri`. const redirectUri = deps.redirectUri; @@ -678,6 +680,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { scopes: requestedScopes, resource: client.resource ?? undefined, endpointUrlPolicy: deps.endpointUrlPolicy, + fetch, }).pipe( Effect.mapError( (cause) => @@ -854,6 +857,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { code: input.code, resource: client.resource ?? undefined, endpointUrlPolicy: deps.endpointUrlPolicy, + fetch, }).pipe( Effect.mapError( (cause) =>