658 lines · 25.2 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// End-to-end shape test for multi-scope OAuth on the OpenAPI plugin.//// Models the production scenario: an org-level admin uploads the shared// client credentials, each member of the org runs their own OAuth flow,// and each member's access token lives on a per-user Connection. The// Connections primitive owns every secret — they're filtered out of the// user-facing `secrets.list()` automatically.// --------------------------------------------------------------------------- import { afterEach, expect, layer } from "@effect/vitest";import { Effect, Layer, Schema } from "effect";import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi";import { FetchHttpClient, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http";import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import {  collectSchemas,  createExecutor,  definePlugin,  makeInMemoryBlobStore,  Scope,  ScopeId,  SecretId,  SetSecretInput,  type InvokeOptions,  type SecretProvider,} from "@executor-js/sdk";import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory"; import { openApiPlugin } from "./plugin";import { OAuth2Auth } from "./types"; const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; // ---------------------------------------------------------------------------// Test API — a single endpoint that echoes the Authorization header so the// test can assert which user's token got injected.// --------------------------------------------------------------------------- class EchoHeaders extends Schema.Class<EchoHeaders>("EchoHeaders")({  authorization: Schema.optional(Schema.String),}) {} const ItemsGroup = HttpApiGroup.make("items").add(  HttpApiEndpoint.get("echoHeaders", "/echo-headers", { success: EchoHeaders }),); const TestApi = HttpApi.make("testApi").add(ItemsGroup);const specJson = JSON.stringify(OpenApi.fromApi(TestApi)); const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) =>  handlers.handle("echoHeaders", () =>    Effect.gen(function* () {      const req = yield* HttpServerRequest.HttpServerRequest;      return new EchoHeaders({        authorization: req.headers["authorization"],      });    }),  ),); const ApiLive = HttpApiBuilder.layer(TestApi).pipe(Layer.provide(ItemsGroupLive)); const TestLayer = HttpRouter.serve(ApiLive, { disableListenLog: true, disableLogger: true }).pipe(  Layer.provideMerge(NodeHttpServer.layerTest),); // ---------------------------------------------------------------------------// Fetch override for the token endpoint. Each user's OAuth callback code// deterministically maps to a different access_token in the mock// response so we can assert per-user isolation at invocation time.// --------------------------------------------------------------------------- const originalFetch = globalThis.fetch; const mockTokenFetch = (tokenByCode: Record<string, string>) => {  globalThis.fetch = Object.assign(async (_input: RequestInfo | URL, init?: RequestInit) => {    const url = typeof _input === "string" ? _input : _input.toString();    if (!url.includes("token.example.com")) {      return originalFetch(_input, init);    }    const bodyText =      init?.body instanceof URLSearchParams        ? init.body.toString()        : typeof init?.body === "string"          ? init.body          : "";    const params = new URLSearchParams(bodyText);    const code = params.get("code") ?? "";    const token = tokenByCode[code];    if (!token) {      return new Response(JSON.stringify({ error: "invalid_grant", code }), {        status: 400,        headers: { "content-type": "application/json" },      });    }    return new Response(      JSON.stringify({        access_token: token,        token_type: "Bearer",        refresh_token: `${token}-refresh`,      }),      { status: 200, headers: { "content-type": "application/json" } },    );  }, { preconnect: originalFetch.preconnect });}; afterEach(() => {  globalThis.fetch = originalFetch;}); // ---------------------------------------------------------------------------// Tests// --------------------------------------------------------------------------- layer(TestLayer)("OpenAPI multi-scope OAuth", (it) => {  it.effect("per-user Connections coexist with a shared org-level client credential", () =>    Effect.gen(function* () {      const secretStore = new Map<string, string>();      const key = (scope: string, id: string) => `${scope} ${id}`;      const memoryProvider: SecretProvider = {        key: "memory",        writable: true,        get: (id, scope) => Effect.sync(() => secretStore.get(key(scope, id)) ?? null),        set: (id, value, scope) =>          Effect.sync(() => {            secretStore.set(key(scope, id), value);          }),        delete: (id, scope) => Effect.sync(() => secretStore.delete(key(scope, id))),      };      const memorySecretsPlugin = definePlugin(() => ({        id: "memory-secrets" as const,        storage: () => ({}),        secretProviders: [memoryProvider],      }));      const clientLayer = FetchHttpClient.layer;      const server = yield* HttpServer.HttpServer;      const address = server.address;      if (address._tag !== "TcpAddress") return yield* Effect.die("test server must bind to TCP");      const baseUrl = `http://127.0.0.1:${address.port}`;      const plugins = [        openApiPlugin({ httpClientLayer: clientLayer }),        memorySecretsPlugin(),      ] as const;       const schema = collectSchemas(plugins);      const adapter = makeMemoryAdapter({ schema });      const blobs = makeInMemoryBlobStore();       const now = new Date();      const orgScope = new Scope({        id: ScopeId.make("org"),        name: "acme-org",        createdAt: now,      });      const aliceScope = new Scope({        id: ScopeId.make("user-alice"),        name: "alice",        createdAt: now,      });      const bobScope = new Scope({        id: ScopeId.make("user-bob"),        name: "bob",        createdAt: now,      });       const adminExec = yield* createExecutor({        scopes: [orgScope],        adapter,        blobs,        plugins,        onElicitation: "accept-all",      });      const aliceExec = yield* createExecutor({        scopes: [aliceScope, orgScope],        adapter,        blobs,        plugins,        onElicitation: "accept-all",      });      const bobExec = yield* createExecutor({        scopes: [bobScope, orgScope],        adapter,        blobs,        plugins,        onElicitation: "accept-all",      });       // -------------------------------------------------------------      // 1. Admin seeds the org-level client credentials.      // -------------------------------------------------------------      yield* adminExec.secrets.set(        new SetSecretInput({          id: SecretId.make("petstore_client_id"),          scope: orgScope.id,          name: "Petstore Client ID",          value: "client-abc",        }),      );      yield* adminExec.secrets.set(        new SetSecretInput({          id: SecretId.make("petstore_client_secret"),          scope: orgScope.id,          name: "Petstore Client Secret",          value: "secret-xyz",        }),      );       // -------------------------------------------------------------      // 2. Each user runs startOAuth + centralized OAuth completion to mint a      //    per-user Connection.      // -------------------------------------------------------------      mockTokenFetch({        "code-alice": "alice-token",        "code-bob": "bob-token",      });       const startInputFor = (user: string, scope: ScopeId) => ({        sourceId: "petstore",        displayName: `Petstore (${user})`,        securitySchemeName: "oauth2",        authorizationUrl: "https://auth.example.com/authorize",        tokenUrl: "https://token.example.com/token",        redirectUrl: "https://app.example.com/oauth/callback",        clientIdSecretId: "petstore_client_id",        clientSecretSecretId: "petstore_client_secret",        scopes: ["read"],        tokenScope: String(scope),      });       const startAuthorizationCode = (        exec: typeof aliceExec,        input: ReturnType<typeof startInputFor>,      ) =>        exec.oauth.start({          endpoint: input.authorizationUrl,          redirectUrl: input.redirectUrl,          connectionId: `openapi-oauth2-user-${input.sourceId}`,          tokenScope: input.tokenScope,          pluginId: "openapi",          identityLabel: `${input.displayName} OAuth`,          strategy: {            kind: "authorization-code",            authorizationEndpoint: input.authorizationUrl,            tokenEndpoint: input.tokenUrl,            issuerUrl: null,            clientIdSecretId: input.clientIdSecretId,            clientSecretSecretId: input.clientSecretSecretId,            scopes: input.scopes,          },        });       const aliceStart = yield* startAuthorizationCode(        aliceExec,        startInputFor("alice", aliceScope.id),      );      const bobStart = yield* startAuthorizationCode(bobExec, startInputFor("bob", bobScope.id));      if (aliceStart.authorizationUrl === null) {        throw new Error("expected authorizationCode flow for alice");      }      if (bobStart.authorizationUrl === null) {        throw new Error("expected authorizationCode flow for bob");      }       const aliceAuth = yield* aliceExec.oauth.complete({        state: aliceStart.sessionId,        code: "code-alice",      });      const bobAuth = yield* bobExec.oauth.complete({        state: bobStart.sessionId,        code: "code-bob",      });       // With the stable-id fix both users derive the same row id      // string from `sourceId`, but the rows live at different user      // scopes (ids are only unique within a scope). The assertion      // below that `adminConnectionIds` doesn't include either one      // proves admin's stack can't reach either user's row.      expect(aliceAuth.connectionId).toBe(bobAuth.connectionId);      const aliceOAuth2Auth = new OAuth2Auth({        kind: "oauth2",        connectionId: aliceAuth.connectionId,        securitySchemeName: "oauth2",        flow: "authorizationCode",        tokenUrl: "https://token.example.com/token",        authorizationUrl: "https://auth.example.com/authorize",        clientIdSecretId: "petstore_client_id",        clientSecretSecretId: "petstore_client_secret",        scopes: ["read"],      });      const bobOAuth2Auth = new OAuth2Auth({        kind: "oauth2",        connectionId: bobAuth.connectionId,        securitySchemeName: "oauth2",        flow: "authorizationCode",        tokenUrl: "https://token.example.com/token",        authorizationUrl: "https://auth.example.com/authorize",        clientIdSecretId: "petstore_client_id",        clientSecretSecretId: "petstore_client_secret",        scopes: ["read"],      });       // -------------------------------------------------------------      // 3. Each user adds the spec with the auth they just minted.      // -------------------------------------------------------------      yield* aliceExec.openapi.addSpec({        spec: specJson,        scope: aliceScope.id as string,        namespace: "petstore",        baseUrl,        oauth2: aliceOAuth2Auth,      });      yield* bobExec.openapi.addSpec({        spec: specJson,        scope: bobScope.id as string,        namespace: "petstore",        baseUrl,        oauth2: bobOAuth2Auth,      });       // -------------------------------------------------------------      // 4. Invoke through each exec — Authorization must carry that      //    user's token.      // -------------------------------------------------------------      const aliceResult = (yield* aliceExec.tools.invoke(        "petstore.items.echoHeaders",        {},        autoApprove,      )) as { data: { authorization?: string } | null; error: unknown };      expect(aliceResult.error).toBeNull();      expect(aliceResult.data?.authorization).toBe("Bearer alice-token");       const bobResult = (yield* bobExec.tools.invoke(        "petstore.items.echoHeaders",        {},        autoApprove,      )) as { data: { authorization?: string } | null; error: unknown };      expect(bobResult.error).toBeNull();      expect(bobResult.data?.authorization).toBe("Bearer bob-token");       // -------------------------------------------------------------      // 5. Each user's Connection is scoped to them; admin sees none.      // -------------------------------------------------------------      const aliceConnections = yield* aliceExec.connections.list();      const aliceConn = aliceConnections.find((c) => c.id === aliceAuth.connectionId);      expect(String(aliceConn?.scopeId)).toBe("user-alice");       const bobConnections = yield* bobExec.connections.list();      const bobConn = bobConnections.find((c) => c.id === bobAuth.connectionId);      expect(String(bobConn?.scopeId)).toBe("user-bob");       const adminConnectionIds = new Set(        (yield* adminExec.connections.list()).map((c) => c.id as string),      );      expect(adminConnectionIds).not.toContain(String(aliceAuth.connectionId));      expect(adminConnectionIds).not.toContain(String(bobAuth.connectionId));       // -------------------------------------------------------------      // 6. Connection-owned secrets are filtered from secrets.list().      //    Alice only sees the org client creds; her access / refresh      //    tokens are hidden behind the Connection primitive.      // -------------------------------------------------------------      const aliceSecretIds = new Set(        (yield* aliceExec.secrets.list()).map((s) => String(s.id)),      );      expect(aliceSecretIds).toContain("petstore_client_id");      expect(aliceSecretIds).toContain("petstore_client_secret");      expect(aliceSecretIds).not.toContain(`${aliceAuth.connectionId}.access_token`);      expect(aliceSecretIds).not.toContain(`${aliceAuth.connectionId}.refresh_token`);    }),  );   // -------------------------------------------------------------------------  // Regression: repeated `clientCredentials` sign-ins used to mint a fresh  // random UUID per call AND rewrite `source.oauth2.connectionId` to that  // new id, which meant whichever user signed in last owned the pointer  // and everyone else's invocations broke (their scope stack couldn't  // find the previous signer's row). Fix: the Connection id is now a  // stable `openapi-oauth2-app-${sourceId}` *name* — the same string  // across callers — written at the innermost (per-user) scope. Each  // user's stack resolves that one name to their own physical row via  // `findInnermostConnectionRow`, so shared source + per-user credentials  // (secrets shadowed at user scope) keeps producing per-user tokens  // without clobbering each other.  // -------------------------------------------------------------------------  it.effect("clientCredentials sign-in is per-user with a stable shared connection name", () =>    Effect.gen(function* () {      const secretStore = new Map<string, string>();      const key = (scope: string, id: string) => `${scope} ${id}`;      const memoryProvider: SecretProvider = {        key: "memory",        writable: true,        get: (id, scope) => Effect.sync(() => secretStore.get(key(scope, id)) ?? null),        set: (id, value, scope) =>          Effect.sync(() => {            secretStore.set(key(scope, id), value);          }),        delete: (id, scope) => Effect.sync(() => secretStore.delete(key(scope, id))),      };      const memorySecretsPlugin = definePlugin(() => ({        id: "memory-secrets" as const,        storage: () => ({}),        secretProviders: [memoryProvider],      }));      const clientLayer = FetchHttpClient.layer;      const server = yield* HttpServer.HttpServer;      const address = server.address;      if (address._tag !== "TcpAddress") return yield* Effect.die("test server must bind to TCP");      const baseUrl = `http://127.0.0.1:${address.port}`;      const plugins = [        openApiPlugin({ httpClientLayer: clientLayer }),        memorySecretsPlugin(),      ] as const;       const schema = collectSchemas(plugins);      const adapter = makeMemoryAdapter({ schema });      const blobs = makeInMemoryBlobStore();       const now = new Date();      const orgScope = new Scope({        id: ScopeId.make("org"),        name: "acme-org",        createdAt: now,      });      const aliceScope = new Scope({        id: ScopeId.make("user-alice"),        name: "alice",        createdAt: now,      });      const bobScope = new Scope({        id: ScopeId.make("user-bob"),        name: "bob",        createdAt: now,      });       const adminExec = yield* createExecutor({        scopes: [orgScope],        adapter,        blobs,        plugins,        onElicitation: "accept-all",      });      const aliceExec = yield* createExecutor({        scopes: [aliceScope, orgScope],        adapter,        blobs,        plugins,        onElicitation: "accept-all",      });      const bobExec = yield* createExecutor({        scopes: [bobScope, orgScope],        adapter,        blobs,        plugins,        onElicitation: "accept-all",      });       // Org-wide default client_id at org scope. Alice then shadows      // with her own value at user-alice — the common "per-user API      // key that uses client_credentials as the wire protocol"      // pattern. Bob doesn't shadow → he falls through to the org      // default. This exercises scope-stacked secret resolution.      yield* adminExec.secrets.set(        new SetSecretInput({          id: SecretId.make("client_id"),          scope: orgScope.id,          name: "Client ID",          value: "org-client",        }),      );      yield* adminExec.secrets.set(        new SetSecretInput({          id: SecretId.make("client_secret"),          scope: orgScope.id,          name: "Client Secret",          value: "org-secret",        }),      );      yield* aliceExec.secrets.set(        new SetSecretInput({          id: SecretId.make("client_id"),          scope: aliceScope.id,          name: "Alice Client ID",          value: "alice-client",        }),      );      yield* aliceExec.secrets.set(        new SetSecretInput({          id: SecretId.make("client_secret"),          scope: aliceScope.id,          name: "Alice Client Secret",          value: "alice-secret",        }),      );       // client_credentials token endpoint stub. Issues a token that      // encodes which client_id was used, so we can assert each      // user's row ends up with a token minted from *their own*      // credential resolution.

Lines 1–500 of 658.