1662 lines · 61.6 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("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;        expect(String(first?.name)).toBe("main");        expect(String(second?.name)).toBe("main2");         const connections = yield* executor.connections.list({ integration: INTEG });        expect(connections.map((connection) => String(connection.name)).sort()).toEqual([          "main",          "main2",        ]);      }),    ),  );   it.effect("preserves a curated label when reconnecting without an explicit label", () =>    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,          });          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;        expect(first?.identityLabel).toBe("alice@example.com");         // The user renames the connection, then reconnects (no typed label).        yield* executor.connections.update(          { owner: "org", integration: INTEG, name: ConnectionName.make("main") },          { identityLabel: "Finance account" },        );        const second = yield* runFlow;        expect(second?.identityLabel).toBe("Finance account");      }),    ),  );   it.effect("does not overwrite connection identity from id_token claims on refresh", () =>    Effect.scoped(      Effect.gen(function* () {

Lines 1–500 of 1662.