791 lines · 33.6 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest";import { Effect, Predicate } from "effect"; import {  AuthTemplateSlug,  ConnectionName,  IntegrationSlug,  OAuthClientSlug,  ToolName,} from "./ids";import { OAuthRegisterDynamicError } from "./oauth-client";import { definePlugin } from "./plugin";import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";import { serveOAuthTestServer } from "./testing/oauth-test-server"; // RFC 7591 Dynamic Client Registration, end to end://   probe → registerDynamicClient (no pasted client id/secret) → listClients//   → start → complete mints a connection via a PUBLIC client (PKCE, no secret).// The test authorization server's /register endpoint mints a public client when// `token_endpoint_auth_method: "none"` is requested, and its /token endpoint// accepts that client WITHOUT a client_secret — proving the public-client path. const INTEG = IntegrationSlug.make("acme");const TEMPLATE = AuthTemplateSlug.make("oauth");const CLIENT = OAuthClientSlug.make("acme-dcr");const FLOW_REDIRECT_URI = "https://localhost:5394/api/oauth/callback"; const dcrSlugFor = (issuerUrl: string): OAuthClientSlug =>  OAuthClientSlug.make(    `dcr-${new URL(issuerUrl).host      .toLowerCase()      .replace(/[^a-z0-9]+/g, "-")      .replace(/^-+|-+$/g, "")}`,  ); const registerRequestCount = (  requests: readonly { readonly path: string; readonly method: string }[],): number => requests.filter((r) => r.path === "/register" && r.method === "POST").length; const oauthPlugin = definePlugin(() => ({  id: "acme" as const,  storage: () => ({}),  resolveTools: () =>    Effect.succeed({      tools: [{ name: ToolName.make("whoami"), description: "whoami" }],    }),  invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }),  extension: (ctx) => ({    seed: () =>      ctx.core.integrations.register({        slug: INTEG,        description: "Acme",        config: {},      }),  }),}))(); const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; describe("oauth.registerDynamicClient", () => {  it.effect("DCR mints + persists a public (no-secret) client that lists + connects", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();         // Probe surfaces the registration endpoint + advertised auth methods so        // the caller knows a public client is allowed.        const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });        expect(probe.issuer).toBe(server.issuerUrl);        expect(probe.registrationEndpoint).toBe(server.registrationEndpoint);        expect(probe.tokenEndpointAuthMethodsSupported).toContain("none");        expect(probe.resource).toBe(server.mcpResourceUrl);         // Register dynamically — NO client id/secret pasted by the user.        const slug = yield* executor.oauth.registerDynamicClient({          owner: "org",          slug: CLIENT,          issuer: probe.issuer,          registrationEndpoint: probe.registrationEndpoint!,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: probe.resource,          scopes: ["read"],          tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,          clientName: "Acme DCR",          redirectUri: FLOW_REDIRECT_URI,          originIntegration: INTEG,        });        const expectedSlug = dcrSlugFor(server.issuerUrl);        expect(String(slug)).toBe(String(expectedSlug));         // The minted client appears in listClients with a server-issued        // client_id and NO secret ever projected.        const clients = yield* executor.oauth.listClients();        const minted = clients.find((c) => String(c.slug) === String(expectedSlug));        expect(minted).toBeDefined();        expect(minted!.owner).toBe("org");        expect(minted!.grant).toBe("authorization_code");        expect(minted!.clientId.length).toBeGreaterThan(0);        expect(minted!.clientId.startsWith("client_")).toBe(true);        expect(minted!.origin).toEqual({          kind: "dynamic_client_registration",          integration: INTEG,        });        for (const client of clients) {          expect(Object.keys(client)).not.toContain("clientSecret");          expect(JSON.stringify(client)).not.toContain("secret");        }         // The DCR-minted public client drives the full authorization_code +        // PKCE flow with NO client_secret on the token exchange.        const started = yield* executor.oauth.start({          owner: "org",          client: expectedSlug,          clientOwner: "org",          name: ConnectionName.make("main"),          integration: INTEG,          template: TEMPLATE,          redirectUri: FLOW_REDIRECT_URI,        });        expect(started.status).toBe("redirect");        if (started.status !== "redirect") return;        expect(new URL(started.authorizationUrl).searchParams.get("resource")).toBe(          server.mcpResourceUrl,        );         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("main");        expect(String(connection.address)).toBe("tools.acme.org.main");         // The /token request was made WITHOUT a client_secret (public client).        const requests = yield* server.requests;        const registerRequest = requests.find((r) => r.path === "/register" && r.method === "POST");        expect(registerRequest).toBeDefined();        expect(registerRequest!.body).toContain(FLOW_REDIRECT_URI);        expect(registerRequest!.body).toContain("authorization_code");        expect(registerRequest!.body).toContain("refresh_token");        const authorizationRequest = requests.find(          (r) => r.path === "/authorize" && r.method === "GET",        );        expect(authorizationRequest).toBeDefined();        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).toBeDefined();        expect(tokenRequest!.body).not.toContain("client_secret");        expect(new URLSearchParams(tokenRequest!.body).get("resource")).toBe(server.mcpResourceUrl);      }),    ),  );   it.effect("reuses an existing DCR client for the same owner and authorization server", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();        const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });         const first = yield* executor.oauth.registerDynamicClient({          owner: "org",          slug: OAuthClientSlug.make("first-attempt"),          issuer: probe.issuer,          registrationEndpoint: probe.registrationEndpoint!,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: probe.resource,          scopes: ["read"],          tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,          clientName: "Acme DCR",          redirectUri: FLOW_REDIRECT_URI,          originIntegration: INTEG,        });        yield* server.clearRequests;         const second = yield* executor.oauth.registerDynamicClient({          owner: "org",          slug: OAuthClientSlug.make("second-attempt"),          issuer: probe.issuer,          registrationEndpoint: probe.registrationEndpoint!,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: probe.resource,          scopes: ["read"],          tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,          clientName: "Other integration DCR",          redirectUri: FLOW_REDIRECT_URI,          originIntegration: IntegrationSlug.make("other"),        });         expect(second).toBe(first);        const requests = yield* server.requests;        expect(registerRequestCount(requests)).toBe(0);      }),    ),  );   it.effect("does not reuse a DCR client across owners", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();        const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });         const orgSlug = yield* executor.oauth.registerDynamicClient({          owner: "org",          slug: OAuthClientSlug.make("org-attempt"),          issuer: probe.issuer,          registrationEndpoint: probe.registrationEndpoint!,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: probe.resource,          scopes: ["read"],          tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,          clientName: "Org DCR",          redirectUri: FLOW_REDIRECT_URI,          originIntegration: INTEG,        });        yield* server.clearRequests;         const userSlug = yield* executor.oauth.registerDynamicClient({          owner: "user",          slug: OAuthClientSlug.make("user-attempt"),          issuer: probe.issuer,          registrationEndpoint: probe.registrationEndpoint!,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: probe.resource,          scopes: ["read"],          tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,          clientName: "User DCR",          redirectUri: FLOW_REDIRECT_URI,          originIntegration: INTEG,        });         expect(String(userSlug)).toBe(String(orgSlug));        const requests = yield* server.requests;        expect(registerRequestCount(requests)).toBe(1);      }),    ),  );   it.effect("does NOT reuse a legacy DCR row with no stored issuer (mints fresh)", () =>    Effect.scoped(      Effect.gen(function* () {        // Post-migration, the reuse lookup keys strictly on a non-null        // origin_issuer: the fuzzy token-host fallback is gone. A legacy row that        // has not yet been backfilled (null origin_issuer) is therefore NOT        // reused, so a fresh DCR registration happens. The GC migration then        // backfills/GCs any duplicate this transient window mints.        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();        const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });        const legacySlug = OAuthClientSlug.make("cloudflare-mcp");         yield* executor.oauth.createClient({          owner: "org",          slug: legacySlug,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: server.mcpResourceUrl,          grant: "authorization_code",          clientId: "legacy-dcr-client",          clientSecret: "",        });        yield* Effect.promise(() =>          config.db.updateMany("oauth_client", {            where: (b) => b("slug", "=", String(legacySlug)),            set: { origin_kind: null, origin_integration: null, origin_issuer: null },          }),        );        yield* server.clearRequests;         const registered = yield* executor.oauth.registerDynamicClient({          owner: "org",          slug: OAuthClientSlug.make("new-attempt"),          issuer: probe.issuer,          registrationEndpoint: probe.registrationEndpoint!,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: server.mcpResourceUrl,          scopes: ["read"],          tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,          clientName: "Acme DCR",          redirectUri: FLOW_REDIRECT_URI,          originIntegration: INTEG,        });         // A fresh client is registered, not the null-issuer legacy row.        expect(registered).not.toBe(legacySlug);        const requests = yield* server.requests;        expect(registerRequestCount(requests)).toBe(1);      }),    ),  );   it.effect("reuses a legacy DCR row once its origin_issuer is backfilled", () =>    Effect.scoped(      Effect.gen(function* () {        // The post-backfill counterpart: after the GC migration stamps a legacy        // row's origin_issuer, the reuse lookup keys on it and mints no        // duplicate. This is the steady state the migration establishes.        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();        const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });        const legacySlug = OAuthClientSlug.make("cloudflare-mcp");         yield* executor.oauth.createClient({          owner: "org",          slug: legacySlug,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: server.mcpResourceUrl,          grant: "authorization_code",          clientId: "legacy-dcr-client",          clientSecret: "",        });        // Simulate the migration's backfill: legacy DCR stamp + issuer set.        yield* Effect.promise(() =>          config.db.updateMany("oauth_client", {            where: (b) => b("slug", "=", String(legacySlug)),            set: {              origin_kind: "dynamic_client_registration",              origin_integration: null,              origin_issuer: probe.issuer,            },          }),        );        yield* server.clearRequests;         const reused = yield* executor.oauth.registerDynamicClient({          owner: "org",          slug: OAuthClientSlug.make("new-attempt"),          issuer: probe.issuer,          registrationEndpoint: probe.registrationEndpoint!,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: server.mcpResourceUrl,          scopes: ["read"],          tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,          clientName: "Acme DCR",          redirectUri: FLOW_REDIRECT_URI,          originIntegration: INTEG,        });         expect(reused).toBe(legacySlug);        const requests = yield* server.requests;        expect(registerRequestCount(requests)).toBe(0);      }),    ),  );   it.effect("uses resource to distinguish DCR clients only after an issuer already differs", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();        const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });        const resourceA = `${server.issuerUrl}/mcp/a`;        const resourceB = `${server.issuerUrl}/mcp/b`;         const first = yield* executor.oauth.registerDynamicClient({          owner: "org",          slug: OAuthClientSlug.make("first-resource"),          issuer: probe.issuer,          registrationEndpoint: probe.registrationEndpoint!,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: resourceA,          scopes: ["read"],          tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,          clientName: "Resource A",          redirectUri: FLOW_REDIRECT_URI,          originIntegration: INTEG,        });        expect(String(first)).toBe(String(dcrSlugFor(server.issuerUrl)));         const second = yield* executor.oauth.registerDynamicClient({          owner: "org",          slug: OAuthClientSlug.make("second-resource"),          issuer: probe.issuer,          registrationEndpoint: probe.registrationEndpoint!,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: resourceB,          scopes: ["read"],          tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,          clientName: "Resource B",          redirectUri: FLOW_REDIRECT_URI,          originIntegration: INTEG,        });        expect(String(second)).not.toBe(String(first));        expect(String(second)).toContain(String(first));        yield* server.clearRequests;         const third = yield* executor.oauth.registerDynamicClient({          owner: "org",          slug: OAuthClientSlug.make("third-resource"),          issuer: probe.issuer,          registrationEndpoint: probe.registrationEndpoint!,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: resourceB,          scopes: ["read"],          tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,          clientName: "Resource B again",          redirectUri: FLOW_REDIRECT_URI,          originIntegration: IntegrationSlug.make("other"),        });         expect(third).toBe(second);        const requests = yield* server.requests;        expect(registerRequestCount(requests)).toBe(0);      }),    ),  );   it.effect("does not reuse a resource-scoped DCR client for a resource-less request", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();        const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });        const resourceA = `${server.issuerUrl}/mcp/a`;        const resourceB = `${server.issuerUrl}/mcp/b`;         // Two resource-scoped clients for the same authorization server. The first        // takes the base `dcr-<host>` slug; the second gets a resource-suffixed one.        const scopedA = yield* executor.oauth.registerDynamicClient({          owner: "org",          slug: OAuthClientSlug.make("scoped-a"),          issuer: probe.issuer,          registrationEndpoint: probe.registrationEndpoint!,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: resourceA,          scopes: ["read"],          tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,          clientName: "Resource A",          redirectUri: FLOW_REDIRECT_URI,          originIntegration: INTEG,        });        const scopedB = yield* executor.oauth.registerDynamicClient({          owner: "org",          slug: OAuthClientSlug.make("scoped-b"),          issuer: probe.issuer,          registrationEndpoint: probe.registrationEndpoint!,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: resourceB,          scopes: ["read"],          tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,          clientName: "Resource B",          redirectUri: FLOW_REDIRECT_URI,          originIntegration: INTEG,        });        expect(String(scopedB)).not.toBe(String(scopedA));        yield* server.clearRequests;         // A resource-LESS request for the same owner + issuer must NOT borrow        // either resource-scoped client (their tokens are bound to a resource):        // it registers a fresh resource-less client.        const resourceLess = yield* executor.oauth.registerDynamicClient({          owner: "org",          slug: OAuthClientSlug.make("resource-less"),          issuer: probe.issuer,          registrationEndpoint: probe.registrationEndpoint!,          authorizationUrl: probe.authorizationUrl,          tokenUrl: probe.tokenUrl,          resource: null,          scopes: ["read"],          tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,          clientName: "Resource-less",          redirectUri: FLOW_REDIRECT_URI,          originIntegration: INTEG,        });        expect(String(resourceLess)).not.toBe(String(scopedA));        expect(String(resourceLess)).not.toBe(String(scopedB));        const requests = yield* server.requests;        expect(registerRequestCount(requests)).toBe(1);         // The minted resource-less client is stored with a null resource, and the        // two resource-scoped clients still exist (nothing was clobbered).        const clients = yield* executor.oauth.listClients();

Lines 1–500 of 791.