1553 lines · 57.7 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest";import { Effect, Predicate } from "effect"; import {  AuthTemplateSlug,  ConnectionName,  IntegrationSlug,  OAuthClientSlug,  OAuthState,  ToolAddress,  ToolName,} from "./ids";import { authToolFailure } from "./auth-tool-failure";import { decodeOAuthCallbackState } from "./oauth";import { OAuthStartError } from "./oauth-client";import { missingGrantedOAuthScopes } from "./oauth-service";import { definePlugin } from "./plugin";import { 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; 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("does not overwrite connection identity from id_token claims on refresh", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({          scopes: ["openid", "email", "profile", "read"],          idTokenClaims: { email: "alice@example.com", sub: "user-1" },          refreshIdTokenClaims: { email: "refreshed@example.com", sub: "user-2" },        });        const harness = yield* makeTestWorkspaceHarness({ plugins });        const { executor, config } = harness;        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,        });        yield* executor.oauth.complete({          state: started.state,          code: callback.code,        });         yield* Effect.promise(() =>          config.db.updateMany("connection", {            where: (b) => b("name", "=", "main"),            set: { expires_at: Date.now() - 60_000 },          }),        );         yield* executor.execute(ToolAddress.make("tools.acme.org.main.whoami"), {});        const refreshed = yield* executor.connections.get({          owner: "org",          integration: INTEG,          name: ConnectionName.make("main"),        });        expect(refreshed?.identityLabel).toBe("alice@example.com");      }),    ),  );   it.effect("start (authorization_code) fails loudly when the executor has no redirectUri", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        // EXPLICIT: construct the executor WITHOUT a redirectUri (null) — there        // is no silent localhost default. The redirect flow must fail loudly        // rather than handing the provider a wrong `http://127.0.0.1/callback`.        const { executor } = yield* makeTestWorkspaceHarness({          plugins,          redirectUri: null,        });        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 error = yield* Effect.flip(          executor.oauth.start({            owner: "org",            client: CLIENT,            clientOwner: "org",            name: ConnectionName.make("main"),            integration: INTEG,            template: TEMPLATE,          }),        );        // `OAuthStartError` carries a typed `message`; the `Predicate.isTagged`        // guard narrows the union so this read is on a typed failure.        expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true);        const startError = error as OAuthStartError;        expect(startError.message).toContain("redirectUri");      }),    ),  );   it.effect("client_credentials start still mints without a redirectUri (no redirect needed)", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        // No redirectUri configured, but client_credentials never redirects —        // it must still mint the connection inline.        const { executor } = yield* makeTestWorkspaceHarness({          plugins,          redirectUri: null,        });        yield* executor.acme.seed(); 

Lines 1–500 of 1553.