251 lines · 9.9 KB · TypeScript
RawDownload
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";import { describe, expect, it } from "@effect/vitest";import { Context, Effect, Layer, Predicate, type Scope } from "effect";import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import {  AuthTemplateSlug,  ConnectionName,  IntegrationSlug,  OAuthClientSlug,  ToolName,} from "./ids";import type { AuthMethodDescriptor } from "./integration";import { definePlugin, type IntegrationRecord } from "./plugin";import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";import { serveOAuthTestServer } from "./testing/oauth-test-server"; // Integration-driven scopes: at connect, `oauth.start` requests EXACTLY the// integration's DECLARED oauth scopes. The OAuth app carries no scope set of its// own, so there is no union and no over-request — the integration is the sole// source of what to request. (Replaces the earlier declared∪client union model.) const INTEG = IntegrationSlug.make("acme");const TEMPLATE = AuthTemplateSlug.make("oauth");const CLIENT = OAuthClientSlug.make("acme-app"); // The integration's DECLARED oauth scopes — the sole source of the request.const DECLARED_SCOPES = ["calendar", "gmail", "drive", "sheets"] as const; /** A plugin whose integration config carries declared oauth scopes, projected *  into an oauth `AuthMethodDescriptor` via `describeAuthMethods` — exactly the *  shape `resolveDeclaredOAuthScopes` reads. `scopes: null` ⇒ no declared oauth *  scopes (the MCP/no-template-scopes case). */const makeScopePlugin = (config: { readonly scopes: readonly string[] | null }) =>  definePlugin(() => ({    id: "acme" as const,    storage: () => ({}),    resolveTools: () =>      Effect.succeed({        tools: [{ name: ToolName.make("whoami"), description: "whoami" }],      }),    invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }),    describeAuthMethods: (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {      const cfg = record.config as { readonly scopes?: readonly string[] | null } | null;      const scopes = cfg?.scopes;      if (scopes == null) {        // No declared oauth scopes — the integration declares an oauth method        // with no template scopes (MCP/DCR-style). Declared scopes resolve to [].        return [{ id: "oauth", label: "OAuth2", kind: "oauth", template: String(TEMPLATE) }];      }      return [        {          id: "oauth",          label: "OAuth2",          kind: "oauth",          template: String(TEMPLATE),          oauth: { scopes },        },      ];    },    extension: (ctx) => ({      seed: () =>        ctx.core.integrations.register({          slug: INTEG,          description: "Acme",          config: { scopes: config.scopes },        }),    }),  }))(); /** Parse the `scope` query param from an authorize URL into an ordered list. */const scopesFromAuthorizeUrl = (authorizationUrl: string): readonly string[] => {  const raw = new URL(authorizationUrl).searchParams.get("scope");  return raw == null || raw.length === 0 ? [] : raw.split(" ");}; describe("oauth.start integration-driven scopes", () => {  it.effect(    "(a) requests exactly the integration's declared scopes (the app contributes none)",    () =>      Effect.scoped(        Effect.gen(function* () {          const server = yield* serveOAuthTestServer({ scopes: [...DECLARED_SCOPES] });          const plugins = [            memoryCredentialsPlugin(),            makeScopePlugin({ scopes: DECLARED_SCOPES }),          ] as const;          const { executor } = yield* makeTestWorkspaceHarness({ plugins });          yield* executor.acme.seed();           // The app is pure identity — no scope set.          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;           // The authorize URL requests exactly the integration's declared scopes.          expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([...DECLARED_SCOPES]);        }),      ),  );   it.effect("(b) when the integration declares no oauth scopes, start requests none", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: [] });        // `scopes: null` ⇒ the integration declares an oauth method with no        // template scopes ⇒ declared scopes resolve to [] ⇒ no scope is requested.        const plugins = [memoryCredentialsPlugin(), makeScopePlugin({ scopes: null })] as const;        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",        });         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;         expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([]);      }),    ),  );}); // -----------------------------------------------------------------------------// (c) The recorded `oauth_scope` reflects the requested (declared) scopes when// the AS omits `scope`. A minimal inline token endpoint handles the// client_credentials grant and deliberately OMITS `scope` from its response,// forcing the recorded-scope fallback (`token.scope ?? requested.join(" ")`).// ----------------------------------------------------------------------------- /** A minimal token endpoint serving the client_credentials grant and OMITTING *  `scope` from its response, forcing the recorded-scope fallback. Returns a *  scoped effect (mirrors `serveOAuthTestServer`); `yield*` it inside an already *  `Effect.scoped` test. */const serveScopelessTokenServer = (): Effect.Effect<  { readonly tokenEndpoint: string },  unknown,  Scope.Scope> =>  Effect.gen(function* () {    const context = yield* Layer.build(      Layer.fresh(        HttpServer.serve(          HttpServerRequest.HttpServerRequest.asEffect().pipe(            Effect.map((request: HttpServerRequest.HttpServerRequest) => {              if (request.url.startsWith("/token") && request.method === "POST") {                // A Bearer access token WITHOUT a `scope` field — the AS omits                // it, so the recorded scope falls back to the requested set.                return HttpServerResponse.jsonUnsafe(                  {                    access_token: `at_${Math.random().toString(36).slice(2)}`,                    token_type: "Bearer",                    expires_in: 3600,                  },                  { status: 200, headers: { "cache-control": "no-store" } },                );              }              return HttpServerResponse.jsonUnsafe({ error: "not_found" }, { status: 404 });            }),          ),        ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)),      ),    );    const server = Context.get(context, HttpServer.HttpServer);    const address = server.address;    if (!Predicate.isTagged(address, "TcpAddress")) {      return yield* Effect.die(`Expected a TcpAddress, got ${JSON.stringify(address)}`);    }    return { tokenEndpoint: `http://127.0.0.1:${address.port}/token` };  }); describe("oauth.start recorded scope fallback", () => {  it.effect(    "(c) records the requested (declared) scopes when the authorization server omits scope",    () =>      Effect.scoped(        Effect.gen(function* () {          const tokenServer = yield* serveScopelessTokenServer();          const plugins = [            memoryCredentialsPlugin(),            makeScopePlugin({ scopes: DECLARED_SCOPES }),          ] as const;          const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });          yield* executor.acme.seed();           // A client_credentials client so `start` mints inline (no redirect),          // exchanging against the scopeless token endpoint.          yield* executor.oauth.createClient({            owner: "org",            slug: CLIENT,            authorizationUrl: "http://127.0.0.1/authorize",            tokenUrl: tokenServer.tokenEndpoint,            grant: "client_credentials",            clientId: "test-client",            clientSecret: "test-secret",          });           const started = yield* executor.oauth.start({            owner: "org",            client: CLIENT,            clientOwner: "org",            name: ConnectionName.make("cc"),            integration: INTEG,            template: TEMPLATE,          });          expect(started.status).toBe("connected");           // The connection's recorded `oauth_scope` is the requested (declared) set          // since the AS omitted `scope`.          const row = yield* Effect.promise(() =>            config.db.findFirst("connection", {              where: (b) => b("name", "=", "cc"),            }),          );          expect(row?.oauth_scope).toBe("calendar gmail drive sheets");        }),      ),  );});