502 lines · 19.9 KB · TypeScript
RawDownload
// oxlint-disable executor/no-unknown-error-message -- boundary: every `.message`// read below is on a TYPED error under test (EMA / OAuth2Error), where the// rendered message is the assertion target.// ---------------------------------------------------------------------------// Protocol conformance for MCP Enterprise-Managed Authorization// (draft-ietf-oauth-identity-assertion-authz-grant-04).//// The fixtures are generic protocol servers, not fakes of a named product: one// plays the enterprise IdP Authorization Server, one plays the MCP server's// Resource Authorization Server, and they are wired to each other exactly as// the draft describes (RFC 8414 metadata → `jwks_uri` → RS256 verification).// They are STRICTER than deployed servers on purpose — a lenient fixture would// let a client bug pass here and fail in production.// --------------------------------------------------------------------------- import { assert, describe, expect, it } from "@effect/vitest";import { Effect, Predicate, Ref, Schema } from "effect";import { HttpServerResponse } from "effect/unstable/http"; import {  OAuthAuthorizationServerMetadataSchema,  supportsIdJagGrantProfile,} from "./oauth-discovery";import {  mintEnterpriseManagedAccessToken,  runEnterpriseManagedAuthorization,  type EnterpriseManagedAuthorizationError,} from "./oauth-ema";import { exchangeSubjectTokenForIdJag, redeemIdJagAssertion } from "./oauth-helpers";import { serveOAuthTestServer, serveTestHttpApp, type OAuthTestServerShape } from "./testing"; const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" as const;const ID_JAG_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag"; const CLIENT_AT_IDP = "mcp-client-at-idp";const CLIENT_AT_RESOURCE = "mcp-client-at-resource"; interface EnterpriseFixture {  readonly idp: OAuthTestServerShape;  readonly resource: OAuthTestServerShape;  readonly subjectToken: string;} /** Stand up the two authorization servers of the profile plus a signed-in user *  at the IdP, and return the identity assertion that single sign-on produced. *  The IdP maps the client's IdP-side id to its DIFFERENT resource-side id *  (draft §5), so every test here exercises the cross-domain client_id handling *  rather than the degenerate same-id case. */const enterpriseFixture = (  overrides: {    readonly idp?: Parameters<typeof serveOAuthTestServer>[0];    readonly resourceAdvertisesProfile?: boolean;    readonly resourceGrantableScopes?: readonly string[];  } = {},) =>  Effect.gen(function* () {    const idp = yield* serveOAuthTestServer({      clients: { [CLIENT_AT_IDP]: null },      scopes: ["mcp.read", "mcp.write"],      ...overrides.idp,      enterpriseIdp: {        resourceClientIds: { [CLIENT_AT_IDP]: CLIENT_AT_RESOURCE },        ...overrides.idp?.enterpriseIdp,      },    });    const resource = yield* serveOAuthTestServer({      clients: { [CLIENT_AT_RESOURCE]: null },      scopes: ["mcp.read", "mcp.write"],      ...(overrides.resourceAdvertisesProfile === false        ? {}        : {            enterpriseResourceServer: {              trustedIdpIssuer: idp.issuerUrl,              ...(overrides.resourceGrantableScopes === undefined                ? {}                : { grantableScopes: overrides.resourceGrantableScopes }),            },          }),    });    const session = yield* idp.completeAuthorizationCodeTokenFlow({      clientId: CLIENT_AT_IDP,      clientSecret: "",      scopes: ["mcp.read", "mcp.write"],    });    return { idp, resource, subjectToken: session.accessToken } satisfies EnterpriseFixture;  }); const chainInput = (fixture: EnterpriseFixture, scopes: readonly string[]) => ({  idp: { tokenUrl: fixture.idp.tokenEndpoint, clientId: CLIENT_AT_IDP },  resourceAuthorizationServer: {    tokenUrl: fixture.resource.tokenEndpoint,    issuer: fixture.resource.issuerUrl,    clientId: CLIENT_AT_RESOURCE,  },  subjectToken: fixture.subjectToken,  subjectTokenType: ACCESS_TOKEN_TYPE,  scopes,}); const decodeMetadata = Schema.decodeUnknownEffect(OAuthAuthorizationServerMetadataSchema); /** Fetch the resource server's RFC 8414 metadata the way the connect path does *  and decode it through the PRODUCTION schema, so the profile-detection *  assertions run against a real document parsed by the real decoder. A local *  restatement would pass even if the production schema silently dropped *  `authorization_grant_profiles_supported` — the field this whole gate reads. */const resourceMetadata = (fixture: EnterpriseFixture) =>  Effect.gen(function* () {    const response = yield* Effect.promise(() =>      // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: reads the fixture's metadata document exactly as the connect path's discovery does      globalThis.fetch(`${fixture.resource.issuerUrl}/.well-known/oauth-authorization-server`),    );    return yield* decodeMetadata(yield* Effect.promise((): Promise<unknown> => response.json()));  }); describe("enterprise-managed authorization: the ID-JAG chain", () => {  it.effect("mints an MCP access token from an enterprise identity assertion", () =>    Effect.scoped(      Effect.gen(function* () {        const fixture = yield* enterpriseFixture();        const metadata = yield* resourceMetadata(fixture);         expect(          supportsIdJagGrantProfile(metadata),          "the resource authorization server advertises the id-jag grant profile",        ).toBe(true);        expect(          metadata.grant_types_supported,          "draft §7.2: advertising the profile requires advertising jwt-bearer too",        ).toContain("urn:ietf:params:oauth:grant-type:jwt-bearer");         const grant = yield* runEnterpriseManagedAuthorization({          ...chainInput(fixture, ["mcp.read", "mcp.write"]),          authorizationServerMetadata: metadata,          resourceAuthorizationServer: { clientId: CLIENT_AT_RESOURCE },        });         expect(grant.scope, "the granted scope survives the whole chain").toBe(          "mcp.read mcp.write",        );        expect(          yield* fixture.resource.acceptsAccessToken(grant.token.access_token),          "the MCP server's authorization server issued the access token",        ).toBe(true);        expect(          grant.token.refresh_token,          "draft §4.4.3: redeeming an ID-JAG issues no refresh token",        ).toBeUndefined();        expect(          grant.token.token_type,          "the redemption yields an ordinary bearer access token (case-normalised by the OAuth library)",        ).toBe("bearer");      }),    ),  );   it.effect("re-runs the exchange on every mint rather than reusing an assertion", () =>    Effect.scoped(      Effect.gen(function* () {        const fixture = yield* enterpriseFixture();        const first = yield* mintEnterpriseManagedAccessToken(chainInput(fixture, ["mcp.read"]));        const second = yield* mintEnterpriseManagedAccessToken(chainInput(fixture, ["mcp.read"]));         expect(          second.token.access_token,          "an expired access token is replaced, never re-issued",        ).not.toBe(first.token.access_token);        const exchanges = (yield* fixture.idp.requests).filter(          (entry) => entry.path === "/token" && entry.body.includes("token-exchange"),        );        expect(          exchanges.length,          "each renewal goes back to the IdP so its policy is re-evaluated",        ).toBe(2);      }),    ),  );   it.effect("narrows the access token to the scopes the resource server grants", () =>    Effect.scoped(      Effect.gen(function* () {        const fixture = yield* enterpriseFixture({ resourceGrantableScopes: ["mcp.read"] });        const grant = yield* mintEnterpriseManagedAccessToken(          chainInput(fixture, ["mcp.read", "mcp.write"]),        );        expect(          grant.scope,          "the resource authorization server may grant a subset of the assertion's scope",        ).toBe("mcp.read");      }),    ),  );   it.effect("carries the IdP's narrowed scope forward instead of re-requesting more", () =>    Effect.scoped(      Effect.gen(function* () {        const fixture = yield* enterpriseFixture({          idp: { enterpriseIdp: { grantScope: () => "mcp.read" } },        });        const grant = yield* mintEnterpriseManagedAccessToken(          chainInput(fixture, ["mcp.read", "mcp.write"]),        );        expect(grant.scope, "enterprise policy narrowed the grant at the IdP").toBe("mcp.read");        const redemptions = (yield* fixture.resource.requests).filter(          (entry) => entry.path === "/token",        );        expect(          redemptions.at(-1)?.body,          "the redemption must not ask for more than the IdP granted",        ).toContain("scope=mcp.read");        expect(redemptions.at(-1)?.body).not.toContain("mcp.write");      }),    ),  );}); describe("enterprise-managed authorization: failure taxonomy", () => {  it.effect("reports an unadvertised grant profile as the one fallback-safe failure", () =>    Effect.scoped(      Effect.gen(function* () {        const fixture = yield* enterpriseFixture({ resourceAdvertisesProfile: false });        const metadata = yield* resourceMetadata(fixture);        expect(supportsIdJagGrantProfile(metadata)).toBe(false);         const error = yield* runEnterpriseManagedAuthorization({          ...chainInput(fixture, ["mcp.read"]),          authorizationServerMetadata: metadata,          resourceAuthorizationServer: { clientId: CLIENT_AT_RESOURCE },        }).pipe(Effect.flip);         expect(          Predicate.isTagged(error, "EmaGrantProfileUnsupported"),          "the one tag the connect path catches: a server that does not implement the profile gets the ordinary OAuth flow",        ).toBe(true);        expect(          (yield* fixture.idp.requests).some((entry) => entry.body.includes("token-exchange")),          "detection happens before any token is exchanged",        ).toBe(false);      }),    ),  );   it.effect("reports an IdP policy refusal as blocked-by-admin, never as a fallback", () =>    Effect.scoped(      Effect.gen(function* () {        const fixture = yield* enterpriseFixture({          idp: {            enterpriseIdp: {              denyExchangeWith: {                error: "unauthorized_client",                errorDescription: "Policy does not permit this client to access the target server.",              },            },          },        });        const metadata = yield* resourceMetadata(fixture);        const error: EnterpriseManagedAuthorizationError = yield* runEnterpriseManagedAuthorization(          {            ...chainInput(fixture, ["mcp.read"]),            authorizationServerMetadata: metadata,            resourceAuthorizationServer: { clientId: CLIENT_AT_RESOURCE },          },        ).pipe(Effect.flip);         assert(          Predicate.isTagged(error, "EmaPolicyDenied"),          "offering interactive OAuth here would route the user around enterprise policy, so this tag is NOT the one the connect path catches",        );        expect(error.error).toBe("unauthorized_client");        expect(          (yield* fixture.resource.requests).some((entry) => entry.path === "/token"),          "a denied exchange never reaches the resource authorization server",        ).toBe(false);      }),    ),  );   it.effect("reports a dead identity assertion as needing single sign-on again", () =>    Effect.scoped(      Effect.gen(function* () {        const fixture = yield* enterpriseFixture();        yield* fixture.idp.revokeAccessToken(fixture.subjectToken);         const error = yield* mintEnterpriseManagedAccessToken(          chainInput(fixture, ["mcp.read"]),        ).pipe(Effect.flip);         expect(          Predicate.isTagged(error, "EmaSubjectTokenRejected"),          "a dead assertion needs a fresh single sign-on, not the interactive per-server flow",        ).toBe(true);      }),    ),  );   it.effect("rejects an ID-JAG minted for a different authorization server", () =>    Effect.scoped(      Effect.gen(function* () {        const fixture = yield* enterpriseFixture();        const otherResource = yield* serveOAuthTestServer({          clients: { [CLIENT_AT_RESOURCE]: null },          enterpriseResourceServer: { trustedIdpIssuer: fixture.idp.issuerUrl },        });         // Ask the IdP for an assertion whose `aud` names the OTHER server, then        // present it here. The signature is genuine and the client is the right        // one; only the audience is wrong, which is the whole confused-deputy        // attack the `aud` check exists to stop.        const misdirected = yield* exchangeSubjectTokenForIdJag({          tokenUrl: fixture.idp.tokenEndpoint,          clientId: CLIENT_AT_IDP,          subjectToken: fixture.subjectToken,          subjectTokenType: ACCESS_TOKEN_TYPE,          audience: otherResource.issuerUrl,        });         const error = yield* redeemIdJagAssertion({          tokenUrl: fixture.resource.tokenEndpoint,          clientId: CLIENT_AT_RESOURCE,          assertion: misdirected.assertion,        }).pipe(Effect.flip);         expect(error.error).toBe("invalid_grant");        expect(error.message).toContain("does not name this authorization server");      }),    ),  );   it.effect("rejects an assertion whose header typ is not oauth-id-jag+jwt", () =>    Effect.scoped(      Effect.gen(function* () {        const fixture = yield* enterpriseFixture({          idp: { enterpriseIdp: { assertionTyp: "JWT" } },        });        const error = yield* mintEnterpriseManagedAccessToken(          chainInput(fixture, ["mcp.read"]),        ).pipe(Effect.flip);         expect(Predicate.isTagged(error, "EmaRedemptionRejected")).toBe(true);        expect(error.message).toContain("typ must be oauth-id-jag+jwt");      }),    ),  );   it.effect("rejects an expired ID-JAG", () =>    Effect.scoped(      Effect.gen(function* () {        const fixture = yield* enterpriseFixture({          idp: { enterpriseIdp: { idJagExpiresInSeconds: -1 } },        });        const error = yield* mintEnterpriseManagedAccessToken(          chainInput(fixture, ["mcp.read"]),        ).pipe(Effect.flip);         expect(Predicate.isTagged(error, "EmaRedemptionRejected")).toBe(true);        expect(error.message).toContain("has expired");      }),    ),  );   it.effect("rejects an ID-JAG whose client_id claim names a different client", () =>    Effect.scoped(      Effect.gen(function* () {        // The IdP maps this client to a resource-side id nobody registered, so the        // assertion's `client_id` cannot match whoever authenticates the        // redemption. draft §4.4.1 requires the server to refuse.        const fixture = yield* enterpriseFixture({          idp: { enterpriseIdp: { resourceClientIds: { [CLIENT_AT_IDP]: "some-other-client" } } },        });        const error = yield* mintEnterpriseManagedAccessToken(          chainInput(fixture, ["mcp.read"]),        ).pipe(Effect.flip);         expect(Predicate.isTagged(error, "EmaRedemptionRejected")).toBe(true);        expect(error.message).toContain("does not match the authenticated client");      }),    ),  );   it.effect("refuses to treat an ID-JAG as a bearer token for the MCP endpoint", () =>    Effect.scoped(      Effect.gen(function* () {        const fixture = yield* enterpriseFixture();        const grant = yield* exchangeSubjectTokenForIdJag({          tokenUrl: fixture.idp.tokenEndpoint,          clientId: CLIENT_AT_IDP,          subjectToken: fixture.subjectToken,          subjectTokenType: ACCESS_TOKEN_TYPE,          audience: fixture.resource.issuerUrl,        });         const response = yield* Effect.promise(() =>          // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: presents the assertion as a raw bearer token, which no product code path would do          globalThis.fetch(fixture.resource.mcpResourceUrl, {            method: "POST",            headers: { authorization: `Bearer ${grant.assertion}` },          }),        );        expect(          response.status,          "an authorization grant is not an access token and the resource must say so",        ).toBe(401);      }),    ),  );}); // ---------------------------------------------------------------------------// Token-exchange response contract (draft §4.3.4). These need a server that// answers WRONGLY, which the conformant fixture above will never do.// --------------------------------------------------------------------------- const serveTokenEndpoint = (body: Readonly<Record<string, unknown>>) =>  Effect.gen(function* () {    const requests = yield* Ref.make<readonly string[]>([]);    const server = yield* serveTestHttpApp((request) =>      Effect.gen(function* () {        const text = yield* request.text.pipe(Effect.catch(() => Effect.succeed("")));        yield* Ref.update(requests, (all) => [...all, text]);        return HttpServerResponse.jsonUnsafe(body, { status: 200 });      }),    );    return { tokenUrl: `${server.baseUrl}/token`, requests: Ref.get(requests) };  }); describe("enterprise-managed authorization: token exchange response contract", () => {  it.effect("rejects a response whose issued_token_type is not an ID-JAG", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveTokenEndpoint({          issued_token_type: "urn:ietf:params:oauth:token-type:access_token",          access_token: "not-an-assertion",          token_type: "N_A",        });        const error = yield* exchangeSubjectTokenForIdJag({          tokenUrl: server.tokenUrl,          clientId: CLIENT_AT_IDP,          subjectToken: "assertion",          subjectTokenType: ACCESS_TOKEN_TYPE,          audience: "https://resource.example",        }).pipe(Effect.flip);        expect(error.message).toContain("issued_token_type");      }),    ),  );   it.effect("rejects a response whose token_type is not the N_A sentinel", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveTokenEndpoint({          issued_token_type: ID_JAG_TOKEN_TYPE,          access_token: "assertion",          token_type: "Bearer",        });        const error = yield* exchangeSubjectTokenForIdJag({          tokenUrl: server.tokenUrl,          clientId: CLIENT_AT_IDP,          subjectToken: "assertion",          subjectTokenType: ACCESS_TOKEN_TYPE,          audience: "https://resource.example",        }).pipe(Effect.flip);        expect(          error.message,          "a Bearer token here would be a live credential the client would treat as a grant",        ).toContain("token_type");      }),    ),  );   it.effect("sends the draft §4.3 parameters verbatim", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveTokenEndpoint({          issued_token_type: ID_JAG_TOKEN_TYPE,          access_token: "assertion",          token_type: "N_A",        });        yield* exchangeSubjectTokenForIdJag({          tokenUrl: server.tokenUrl,          clientId: CLIENT_AT_IDP,          subjectToken: "the-assertion",          subjectTokenType: ACCESS_TOKEN_TYPE,          audience: "https://auth.example/",          resource: "https://mcp.example/mcp",          scopes: ["mcp.read"],        });        const sent = new URLSearchParams((yield* server.requests)[0] ?? "");        expect(sent.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:token-exchange");        expect(sent.get("requested_token_type")).toBe(ID_JAG_TOKEN_TYPE);        expect(sent.get("audience"), "EMA §4: the audience is the resource AS issuer").toBe(          "https://auth.example/",        );        expect(sent.get("resource"), "EMA §4: the resource is the MCP server's identifier").toBe(          "https://mcp.example/mcp",        );        expect(sent.get("subject_token")).toBe("the-assertion");        expect(sent.get("subject_token_type")).toBe(ACCESS_TOKEN_TYPE);        expect(sent.get("scope")).toBe("mcp.read");      }),    ),

Lines 1–500 of 502.