2578 lines · 101.0 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest";import { Deferred, Effect, Fiber, Predicate } from "effect";import { withQueryContext } from "@executor-js/fumadb/query"; import {  AuthTemplateSlug,  ConnectionName,  IntegrationSlug,  OAuthClientSlug,  OAuthState,  ProviderKey,  Subject,  Tenant,  ToolAddress,  ToolName,} from "./ids";import { authToolFailure } from "./auth-tool-failure";import { createExecutor } from "./executor";import { decodeOAuthCallbackState } from "./oauth";import { OAuthStartError } from "./oauth-client";import { missingGrantedOAuthScopes } from "./oauth-service";import { definePlugin } from "./plugin";import type { CredentialProvider } from "./provider";import { makeTestConfig, makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";import { ToolResult } from "./tool-result";import { serveOAuthTestServer } from "./testing/oauth-test-server"; // Milestone 2: prove the v2 `oauth.start` / `oauth.complete` token-minting flow// and OAuth access-token refresh end to end against the test authorization// server. const INTEG = IntegrationSlug.make("acme");const TEMPLATE = AuthTemplateSlug.make("oauth");const CLIENT = OAuthClientSlug.make("acme-app"); const oauthPlugin = definePlugin(() => ({  id: "acme" as const,  storage: () => ({}),  resolveTools: () =>    Effect.succeed({      tools: [{ name: ToolName.make("whoami"), description: "whoami" }],    }),  describeAuthMethods: (record) => {    const config = record.config as { readonly scopes?: readonly string[] } | null;    return [      {        id: "oauth",        label: "OAuth2",        kind: "oauth" as const,        template: String(TEMPLATE),        oauth: { scopes: config?.scopes ?? [] },      },    ];  },  // Echo the resolved credential value (the OAuth access token) back out.  invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }),  checkHealth: ({ credential }) =>    Effect.succeed({      status: credential.value === null ? "expired" : "healthy",      checkedAt: Date.now(),    }),  extension: (ctx) => ({    seed: (scopes: readonly string[] = []) =>      ctx.core.integrations.register({        slug: INTEG,        description: "Acme",        config: { scopes },      }),  }),}))(); const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; // Stated explicitly where a test builds a SECOND root database handle by hand:// both handles must carry the same owner-policy context to address one// connection, so the values cannot be left to `makeTestConfig`'s defaults.const SHARED_STORE_TENANT = "test-tenant";const SHARED_STORE_SUBJECT = "test-subject"; /** The URL a `fetch` double was handed, however the caller spelled it. */const fetchTarget = (input: Parameters<typeof globalThis.fetch>[0]): string =>  typeof input === "string" ? input : input instanceof URL ? input.href : input.url; /** * A `fetch` that holds token-endpoint requests open AFTER the authorization * server has answered them. * * Parking after the response is the point. The refresh token has been rotated * upstream by then, and the in-flight gate entry is still registered, so a peer * arriving during the park has to resolve against an OPEN grant rather than a * settled one. Parking before the response would prove nothing: the grant would * never reach the server, and a peer that went on to run its own grant would * find the stored token still live and succeed. * * Idle until `arm()`, so connection setup (the authorization-code exchange) * runs through untouched. */const makeTokenRequestPark = () => {  let armed = false;  let onSeen: (() => void) | null = null;  const seen = new Promise<void>((resolve) => {    onSeen = resolve;  });  let onRelease: (() => void) | null = null;  const parked = new Promise<void>((resolve) => {    onRelease = resolve;  });  // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: the park wraps the platform fetch and must delegate back to it, which is the only seam that can hold a token request open mid-grant.  const platformFetch: typeof globalThis.fetch = globalThis.fetch;  const fetch: typeof globalThis.fetch = async (input, init) => {    const response = await platformFetch(input, init);    if (armed && new URL(fetchTarget(input)).pathname === "/token") {      onSeen?.();      await parked;    }    return response;  };  return {    fetch,    arm: () => {      armed = true;    },    /** Resolves once a token request has been answered and is being held. */    seen,    release: () => onRelease?.(),  };}; /** Every refresh-token grant the authorization server was asked for. */const refreshGrantsIn = (  requests: ReadonlyArray<{ readonly path: string; readonly body: string }>,) =>  requests.filter(    (request) => request.path === "/token" && request.body.includes("grant_type=refresh_token"),  ); interface TokenEndpointCall {  readonly host: string;  readonly grantType: string | null;} // Route token-endpoint requests aimed at a *non-loopback* host (the// regional/attacker hosts a multi-site rebind test exercises) back to the// loopback test AS, recording the host + grant each one was sent to. The token// exchange/refresh runs through `oauth4webapi`, which calls the global `fetch`// at request time, so swapping `globalThis.fetch` lets the real// `oauth.complete` / refresh path drive the rebind decision while still hitting// a live authorization server. Loopback traffic (the authorize/login hops)// passes straight through untouched. Returns a restore function.const routeTokenEndpointToLoopback = (  server: { readonly issuerUrl: string },  record: TokenEndpointCall[],): (() => void) => {  // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: oauth4webapi reads the global `fetch` at call time, so doubling it is the only seam to observe the token exchange/refresh host.  const originalFetch = globalThis.fetch;  const loopback = new URL(server.issuerUrl);  const patched: typeof fetch = async (input, init) => {    const requestUrl =      typeof input === "string" ? input : input instanceof URL ? input.href : input.url;    const target = new URL(requestUrl);    if (target.hostname === loopback.hostname) {      return originalFetch(input as Parameters<typeof fetch>[0], init);    }    if (target.pathname === "/token") {      const bodyText =        init?.body instanceof URLSearchParams          ? init.body.toString()          : typeof init?.body === "string"            ? init.body            : input instanceof Request              ? await input.clone().text()              : "";      record.push({        host: target.hostname,        grantType: new URLSearchParams(bodyText).get("grant_type"),      });    }    const rerouted = new URL(loopback.origin);    rerouted.pathname = target.pathname;    rerouted.search = target.search;    return input instanceof Request      ? originalFetch(new Request(rerouted.href, input))      : originalFetch(rerouted.href, init);  };  // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: install the doubled fetch (see above).  globalThis.fetch = patched;  return () => {    // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: restore the original fetch.    globalThis.fetch = originalFetch;  };}; describe("oauth.start / oauth.complete", () => {  it.effect(    "createClient → start (redirect) → complete mints a connection + tools, executable",    () =>      Effect.scoped(        Effect.gen(function* () {          const server = yield* serveOAuthTestServer({ scopes: ["read"] });          const { executor } = yield* makeTestWorkspaceHarness({ plugins });          yield* executor.acme.seed();           yield* executor.oauth.createClient({            owner: "org",            slug: CLIENT,            authorizationUrl: server.authorizationEndpoint,            tokenUrl: server.tokenEndpoint,            grant: "authorization_code",            clientId: "test-client",            clientSecret: "test-secret",            resource: server.mcpResourceUrl,          });           const started = yield* executor.oauth.start({            owner: "org",            client: CLIENT,            clientOwner: "org",            name: ConnectionName.make("main-account"),            integration: INTEG,            template: TEMPLATE,          });          expect(started.status).toBe("redirect");          if (started.status !== "redirect") return;           // Drive the test AS through the authorization request to obtain the          // callback code + echoed state.          const callback = yield* server.completeAuthorizationCodeFlow({            authorizationUrl: started.authorizationUrl,          });          expect(callback.state).toBe(String(started.state));           const connection = yield* executor.oauth.complete({            state: started.state,            code: callback.code,          });          expect(String(connection.name)).toBe("mainAccount");          expect(String(connection.address)).toBe("tools.acme.org.mainAccount");          expect(connection.expiresAt).toBeGreaterThan(Date.now());          const requests = yield* server.requests;          const authorizationRequest = requests.find(            (r) => r.path === "/authorize" && r.method === "GET",          );          expect(authorizationRequest?.query.resource).toBe(server.mcpResourceUrl);          const tokenRequest = requests.find(            (r) => r.path === "/token" && r.method === "POST" && r.body.includes("grant_type"),          );          expect(tokenRequest?.body).toContain(            `resource=${encodeURIComponent(server.mcpResourceUrl)}`,          );           // The connection produced its tools.          const tools = yield* executor.tools.list();          expect(tools.map((t) => String(t.name))).toEqual(["whoami"]);           // Executing the tool resolves the minted access token, which the AS          // recognises as one it issued.          const out = (yield* executor.execute(            ToolAddress.make("tools.acme.org.mainAccount.whoami"),            {},          )) as { token: string };          expect(out.token).toMatch(/^at_/);          expect(yield* server.acceptsAccessToken(out.token)).toBe(true);        }),      ),  );   it.effect("carries the URL org selector in provider state without changing redirect_uri", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        const { executor } = yield* makeTestWorkspaceHarness({          plugins,          oauthCallbackStateOrgSlug: "acme",        });        yield* executor.acme.seed();         yield* executor.oauth.createClient({          owner: "org",          slug: CLIENT,          authorizationUrl: server.authorizationEndpoint,          tokenUrl: server.tokenEndpoint,          grant: "authorization_code",          clientId: "test-client",          clientSecret: "test-secret",        });         const started = yield* executor.oauth.start({          owner: "org",          client: CLIENT,          clientOwner: "org",          name: ConnectionName.make("main-account"),          integration: INTEG,          template: TEMPLATE,        });        expect(started.status).toBe("redirect");        if (started.status !== "redirect") return;         const authorizationUrl = new URL(started.authorizationUrl);        const redirectUri = new URL(authorizationUrl.searchParams.get("redirect_uri") ?? "");        const providerState = authorizationUrl.searchParams.get("state") ?? "";         expect(redirectUri.toString()).toBe("http://localhost/oauth/callback");        expect(providerState).not.toBe(String(started.state));        expect(decodeOAuthCallbackState(providerState)).toEqual({          state: String(started.state),          orgSlug: "acme",        });         const callback = yield* server.completeAuthorizationCodeFlow({          authorizationUrl: started.authorizationUrl,        });        const callbackState = decodeOAuthCallbackState(callback.state);        expect(callbackState).not.toBeNull();        if (callbackState === null) return;         const connection = yield* executor.oauth.complete({          state: OAuthState.make(callbackState.state),          code: callback.code,        });        expect(String(connection.address)).toBe("tools.acme.org.mainAccount");      }),    ),  );   it.effect("records offline_access when a refresh token proves it was granted", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({          scopes: ["offline_access", "read"],          omitTokenResponseScopes: ["offline_access"],        });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed(["offline_access", "read"]);         yield* executor.oauth.createClient({          owner: "org",          slug: CLIENT,          authorizationUrl: server.authorizationEndpoint,          tokenUrl: server.tokenEndpoint,          grant: "authorization_code",          clientId: "test-client",          clientSecret: "test-secret",        });         const started = yield* executor.oauth.start({          owner: "org",          client: CLIENT,          clientOwner: "org",          name: ConnectionName.make("main-account"),          integration: INTEG,          template: TEMPLATE,        });        expect(started.status).toBe("redirect");        if (started.status !== "redirect") return;         const callback = yield* server.completeAuthorizationCodeFlow({          authorizationUrl: started.authorizationUrl,        });        const connection = yield* executor.oauth.complete({          state: started.state,          code: callback.code,        });         expect(connection.oauthScope?.split(/\s+/)).toEqual(["read", "offline_access"]);      }),    ),  );   it.effect("persists connection identity from id_token claims on authorization-code grant", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({          scopes: ["openid", "email", "profile", "read"],          idTokenClaims: { email: "alice@example.com", sub: "user-1" },        });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed(["openid", "email", "profile", "read"]);         yield* executor.oauth.createClient({          owner: "org",          slug: CLIENT,          authorizationUrl: server.authorizationEndpoint,          tokenUrl: server.tokenEndpoint,          grant: "authorization_code",          clientId: "test-client",          clientSecret: "test-secret",        });         const started = yield* executor.oauth.start({          owner: "org",          client: CLIENT,          clientOwner: "org",          name: ConnectionName.make("main"),          integration: INTEG,          template: TEMPLATE,        });        expect(started.status).toBe("redirect");        if (started.status !== "redirect") return;         const callback = yield* server.completeAuthorizationCodeFlow({          authorizationUrl: started.authorizationUrl,        });        const connection = yield* executor.oauth.complete({          state: started.state,          code: callback.code,        });         expect(connection.identityLabel).toBe("alice@example.com");      }),    ),  );   it.effect("keeps an explicit OAuth session identity label over id_token claims", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({          scopes: ["openid", "email", "profile", "read"],          idTokenClaims: { email: "alice@example.com", sub: "user-1" },        });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed(["openid", "email", "profile", "read"]);         yield* executor.oauth.createClient({          owner: "org",          slug: CLIENT,          authorizationUrl: server.authorizationEndpoint,          tokenUrl: server.tokenEndpoint,          grant: "authorization_code",          clientId: "test-client",          clientSecret: "test-secret",        });         const started = yield* executor.oauth.start({          owner: "org",          client: CLIENT,          clientOwner: "org",          name: ConnectionName.make("main"),          integration: INTEG,          template: TEMPLATE,          identityLabel: "Work account",        });        expect(started.status).toBe("redirect");        if (started.status !== "redirect") return;         const callback = yield* server.completeAuthorizationCodeFlow({          authorizationUrl: started.authorizationUrl,        });        const connection = yield* executor.oauth.complete({          state: started.state,          code: callback.code,        });         expect(connection.identityLabel).toBe("Work account");      }),    ),  );   it.effect("newConnection resolves a taken name to the next free suffix", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({          scopes: ["openid", "email", "profile", "read"],          idTokenClaims: { email: "alice@example.com", sub: "user-1" },        });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed(["openid", "email", "profile", "read"]);         yield* executor.oauth.createClient({          owner: "org",          slug: CLIENT,          authorizationUrl: server.authorizationEndpoint,          tokenUrl: server.tokenEndpoint,          grant: "authorization_code",          clientId: "test-client",          clientSecret: "test-secret",        });         const runFlow = Effect.gen(function* () {          const started = yield* executor.oauth.start({            owner: "org",            client: CLIENT,            clientOwner: "org",            name: ConnectionName.make("main"),            integration: INTEG,            template: TEMPLATE,            newConnection: true,          });          expect(started.status).toBe("redirect");          if (started.status !== "redirect") return null;          const callback = yield* server.completeAuthorizationCodeFlow({            authorizationUrl: started.authorizationUrl,          });          return yield* executor.oauth.complete({            state: started.state,            code: callback.code,          });        });         const first = yield* runFlow;        const second = yield* runFlow;

Lines 1–500 of 2578.