336 lines · 13.2 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// The enterprise-managed credential lifecycle, driven through the executor's// own surfaces: connect mints a connection with no browser step, and the token// renewal that follows re-runs the ID-JAG chain without a user.//// The ID-JAG's own expiry has no tier here on purpose: the client never stores// one (see `mintEnterpriseManagedAccessToken`), so "the assertion expired" can// only be observed at the protocol boundary, where `oauth-ema.test.ts` covers// it against a Resource Authorization Server that enforces `exp`.// --------------------------------------------------------------------------- import { assert, describe, expect, it } from "@effect/vitest";import { Effect, Predicate } from "effect"; import {  AuthTemplateSlug,  ConnectionName,  IntegrationSlug,  OAuthClientSlug,  ToolAddress,  ToolName,} from "./ids";import type { OAuthService } from "./oauth-client";import { definePlugin } from "./plugin";import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";import { serveOAuthTestServer, type OAuthTestServerShape } from "./testing/oauth-test-server"; const INTEG = IntegrationSlug.make("acme");const TEMPLATE = AuthTemplateSlug.make("oauth");const IDP_CLIENT = OAuthClientSlug.make("enterprise-idp");const RESOURCE_CLIENT = OAuthClientSlug.make("mcp-server-app");const CONNECTION = ConnectionName.make("work");const TOOL = ToolAddress.make("tools.acme.org.work.whoami"); const CLIENT_AT_IDP = "client-at-idp";const CLIENT_AT_RESOURCE = "client-at-resource";const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" as const; const oauthPlugin = definePlugin(() => ({  id: "acme" as const,  storage: () => ({}),  resolveTools: () =>    Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }] }),  describeAuthMethods: () => [    {      id: "oauth",      label: "OAuth2",      kind: "oauth" as const,      template: String(TEMPLATE),      // A declared scope, so the assertions below follow one concrete scope      // through the exchange, the assertion, the redemption, and the stored row.      oauth: { scopes: ["mcp.read"] },    },  ],  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; interface EnterpriseServers {  readonly idp: OAuthTestServerShape;  readonly resource: OAuthTestServerShape;  readonly subjectToken: string;} const enterpriseServers = (options: {  readonly denyExchangeWith?: { readonly error: string; readonly errorDescription: string };  readonly advertiseProfile?: boolean;  /** Short-lived access tokens put every resolve inside the refresh skew, which   *  is how the renewal path is exercised without waiting an hour. */  readonly resourceTokenExpiresInSeconds?: number;}) =>  Effect.gen(function* () {    const idp = yield* serveOAuthTestServer({      clients: { [CLIENT_AT_IDP]: null },      scopes: ["mcp.read"],      enterpriseIdp: {        resourceClientIds: { [CLIENT_AT_IDP]: CLIENT_AT_RESOURCE },        ...(options.denyExchangeWith ? { denyExchangeWith: options.denyExchangeWith } : {}),      },    });    const resource = yield* serveOAuthTestServer({      clients: { [CLIENT_AT_RESOURCE]: null },      scopes: ["mcp.read"],      ...(options.resourceTokenExpiresInSeconds === undefined        ? {}        : { tokenExpiresInSeconds: options.resourceTokenExpiresInSeconds }),      ...(options.advertiseProfile === false        ? {}        : { enterpriseResourceServer: { trustedIdpIssuer: idp.issuerUrl } }),    });    const session = yield* idp.completeAuthorizationCodeTokenFlow({      clientId: CLIENT_AT_IDP,      clientSecret: "",      scopes: ["mcp.read"],    });    return { idp, resource, subjectToken: session.accessToken } satisfies EnterpriseServers;  }); /** Register the two client identities the profile needs: one at the enterprise *  IdP (authenticates the token exchange) and one at the MCP server's *  authorization server (authenticates the redemption, and is the client the *  ID-JAG's `client_id` claim names). */const registerClients = (createClient: OAuthService["createClient"], servers: EnterpriseServers) =>  Effect.gen(function* () {    yield* createClient({      owner: "org",      slug: IDP_CLIENT,      authorizationUrl: servers.idp.authorizationEndpoint,      tokenUrl: servers.idp.tokenEndpoint,      grant: "authorization_code",      clientId: CLIENT_AT_IDP,      clientSecret: "",    });    yield* createClient({      owner: "org",      slug: RESOURCE_CLIENT,      authorizationUrl: servers.resource.authorizationEndpoint,      tokenUrl: servers.resource.tokenEndpoint,      grant: "id_jag",      clientId: CLIENT_AT_RESOURCE,      clientSecret: "",      resource: servers.resource.mcpResourceUrl,    });  }); const startEnterpriseConnect = (servers: EnterpriseServers) =>  ({    owner: "org",    client: RESOURCE_CLIENT,    clientOwner: "org",    name: CONNECTION,    integration: INTEG,    template: TEMPLATE,    enterprise: {      idpClient: IDP_CLIENT,      idpClientOwner: "org",      subjectToken: servers.subjectToken,      subjectTokenType: ACCESS_TOKEN_TYPE,    },  }) as const; const tokenExchangeCount = (servers: EnterpriseServers) =>  servers.idp.requests.pipe(    Effect.map(      (entries) =>        entries.filter((entry) => entry.path === "/token" && entry.body.includes("token-exchange"))          .length,    ),  ); describe("enterprise-managed connections", () => {  it.effect("connect mints a usable MCP credential with no browser step", () =>    Effect.scoped(      Effect.gen(function* () {        const servers = yield* enterpriseServers({});        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();        yield* registerClients(executor.oauth.createClient, servers);         const started = yield* executor.oauth.start(startEnterpriseConnect(servers));        assert(          started.status === "connected",          "the identity assertion replaces per-server consent, so there is nothing to redirect to",        );        expect(started.connection.oauthScope).toBe("mcp.read");         const invoked = (yield* executor.execute(TOOL, {})) as { readonly token: string };        expect(          yield* servers.resource.acceptsAccessToken(invoked.token),          "the tool runs with a token the MCP server's authorization server issued",        ).toBe(true);        expect(yield* tokenExchangeCount(servers)).toBe(1);      }),    ),  );   it.effect("renews an expiring access token by re-running the chain, with no user", () =>    Effect.scoped(      Effect.gen(function* () {        const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();        yield* registerClients(executor.oauth.createClient, servers);        yield* executor.oauth.start(startEnterpriseConnect(servers));         const first = (yield* executor.execute(TOOL, {})) as { readonly token: string };        const second = (yield* executor.execute(TOOL, {})) as { readonly token: string };         expect(second.token, "the expiring token was replaced").not.toBe(first.token);        expect(          yield* servers.resource.acceptsAccessToken(second.token),          "the replacement came from the resource authorization server",        ).toBe(true);        expect(          yield* tokenExchangeCount(servers),          "every renewal returns to the IdP, so enterprise policy is re-evaluated each time",        ).toBeGreaterThan(1);      }),    ),  );   it.effect("marks the connection expired when the stored identity assertion dies", () =>    Effect.scoped(      Effect.gen(function* () {        const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();        yield* registerClients(executor.oauth.createClient, servers);        yield* executor.oauth.start(startEnterpriseConnect(servers));         yield* servers.idp.revokeAccessToken(servers.subjectToken);        const failure = yield* executor.execute(TOOL, {}).pipe(Effect.flip);        assert(          Predicate.isTagged(failure, "CredentialResolutionError"),          "a dead assertion is a credential verdict, not an execution fault",        );        expect(          failure.reauthRequired,          "only a fresh single sign-on can replace a revoked assertion",        ).toBe(true);        expect(          failure.blockedByAdmin,          "the assertion died; the administrator did not withdraw access",        ).toBeUndefined();         const connections = yield* executor.connections.list();        const connection = connections.find((entry) => String(entry.name) === String(CONNECTION));        expect(          connection?.lastHealth?.status,          "a dead assertion is recorded so the accounts list shows it without a probe",        ).toBe("expired");      }),    ),  );   it.effect("reports a policy withdrawn after connect as blocked-by-admin, not as re-auth", () =>    Effect.scoped(      Effect.gen(function* () {        const servers = yield* enterpriseServers({ resourceTokenExpiresInSeconds: 1 });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();        yield* registerClients(executor.oauth.createClient, servers);        yield* executor.oauth.start(startEnterpriseConnect(servers));        yield* executor.execute(TOOL, {});         // The administrator revokes access AFTER the connection exists. Renewal        // meets the denial where no user is present, which is the only place        // `blockedByAdmin` is ever produced on a credential failure.        yield* servers.idp.setTokenExchangeDenial({          error: "access_denied",          errorDescription: "This user is no longer approved for the requested MCP server.",        });         const failure = yield* executor.execute(TOOL, {}).pipe(Effect.flip);        assert(          Predicate.isTagged(failure, "CredentialResolutionError"),          "an administrator decision is a credential verdict, not a transport failure that retries",        );        expect(          failure.blockedByAdmin,          "signing in again cannot help, and the interactive per-server flow must not be offered as a way around the policy",        ).toBe(true);        expect(failure.oauthErrorCode, "the IdP's own §5.2 code travels structurally").toBe(          "access_denied",        );         const connections = yield* executor.connections.list();        const connection = connections.find((entry) => String(entry.name) === String(CONNECTION));        expect(          connection?.lastHealth?.status,          "the accounts list shows the blocked connection without another probe",        ).toBe("expired");      }),    ),  );   it.effect("refuses to fall back to interactive OAuth when the IdP denies the exchange", () =>    Effect.scoped(      Effect.gen(function* () {        const servers = yield* enterpriseServers({          denyExchangeWith: {            error: "unauthorized_client",            errorDescription: "This client is not approved for the requested MCP server.",          },        });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();        yield* registerClients(executor.oauth.createClient, servers);         const failure = yield* executor.oauth          .start(startEnterpriseConnect(servers))          .pipe(Effect.flip);         assert(Predicate.isTagged(failure, "OAuthStartError"));        expect(          failure.blockedByAdmin,          "the console must be able to branch on blocked-by-admin without reading prose: it decides whether the interactive flow may be offered as an alternative",        ).toBe(true);        expect(          failure.oauthErrorCode,          "the IdP's own verdict travels as a code, so support can trace the decision",        ).toBe("unauthorized_client");        expect(          (yield* executor.connections.list()).length,          "a denied connect leaves no half-made connection behind",        ).toBe(0);      }),    ),  );   it.effect("falls back to the interactive flow when the server lacks the grant profile", () =>    Effect.scoped(      Effect.gen(function* () {        const servers = yield* enterpriseServers({ advertiseProfile: false });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();        yield* registerClients(executor.oauth.createClient, servers);         const started = yield* executor.oauth.start(startEnterpriseConnect(servers));         expect(          started.status,          "a server that never implemented the profile still gets ordinary per-server consent",        ).toBe("redirect");        expect(          yield* tokenExchangeCount(servers),          "no identity assertion is spent on a server that cannot accept one",        ).toBe(0);      }),    ),  );});