Auth hardening CI gaps + SDK consolidation: EMA leg 2 / scopes (#1527) (#1725)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Close #1527 acceptance gaps with automated tests (SEP-2468 iss, SEP-2207 offline_access, SEP-2351 discovery shapes, SEP-2352 AS-migration, EraNegotiationFailed→401 unwrap); lean EMA leg 2 and scope helpers on the v2 SDK; drop deprecated TS baseUrl. Reviewed LGTM.
This commit is contained in:
parent
ec185f1053
commit
a71e6d2012
2 changed files with 1513 additions and 852 deletions
|
|
@ -1,14 +1,9 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
exchangeIdJag,
|
||||
redeemIdJagForAccessToken,
|
||||
} from "@inspector/core/auth/ema/wire.js";
|
||||
import {
|
||||
GRANT_TYPE_JWT_BEARER,
|
||||
GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
TOKEN_TYPE_ID_JAG,
|
||||
TOKEN_TYPE_ID_TOKEN,
|
||||
} from "@inspector/core/auth/ema/constants.js";
|
||||
import { GRANT_TYPE_JWT_BEARER } from "@inspector/core/auth/ema/constants.js";
|
||||
import {
|
||||
EMA_MOCK_IDP_CLIENT_ID,
|
||||
EMA_MOCK_IDP_CLIENT_SECRET,
|
||||
|
|
@ -17,8 +12,24 @@ import {
|
|||
minimalOAuthAsMetadata,
|
||||
} from "../../../integration/mcp/ema-mock-servers.js";
|
||||
|
||||
const { mockDiscoverAndRequestJwtAuthGrant } = vi.hoisted(() => ({
|
||||
mockDiscoverAndRequestJwtAuthGrant: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@modelcontextprotocol/client", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@modelcontextprotocol/client")>();
|
||||
return {
|
||||
...actual,
|
||||
discoverAndRequestJwtAuthGrant: (
|
||||
...args: Parameters<typeof actual.discoverAndRequestJwtAuthGrant>
|
||||
) => mockDiscoverAndRequestJwtAuthGrant(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const IDP_ISSUER = "https://mock-idp.test";
|
||||
const AS_ISSUER = "https://mock-as.test";
|
||||
const MCP_RESOURCE = "https://mcp.test/resource";
|
||||
const ID_TOKEN = "header.idpayload.sig";
|
||||
const ID_JAG = "mock-id-jag-token";
|
||||
const ACCESS_TOKEN = "mock-resource-access-token";
|
||||
|
|
@ -34,34 +45,27 @@ function asMetadata() {
|
|||
};
|
||||
}
|
||||
|
||||
/** Route the mock through the real SDK helper (canaries for message-map drift). */
|
||||
async function useRealDiscoverAndRequestJwtAuthGrant() {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@modelcontextprotocol/client")
|
||||
>("@modelcontextprotocol/client");
|
||||
mockDiscoverAndRequestJwtAuthGrant.mockImplementation((options) =>
|
||||
actual.discoverAndRequestJwtAuthGrant(options),
|
||||
);
|
||||
}
|
||||
|
||||
describe("ema wire", () => {
|
||||
beforeEach(() => {
|
||||
mockDiscoverAndRequestJwtAuthGrant.mockReset();
|
||||
});
|
||||
|
||||
describe("exchangeIdJag", () => {
|
||||
it("posts RFC 8693 token exchange and returns ID-JAG", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/.well-known/oauth-authorization-server")) {
|
||||
return new Response(JSON.stringify(idpMetadata()));
|
||||
}
|
||||
if (url === `${IDP_ISSUER}/token`) {
|
||||
const body = new URLSearchParams(init?.body as string);
|
||||
expect(body.get("grant_type")).toBe(GRANT_TYPE_TOKEN_EXCHANGE);
|
||||
expect(body.get("subject_token")).toBe(ID_TOKEN);
|
||||
expect(body.get("subject_token_type")).toBe(TOKEN_TYPE_ID_TOKEN);
|
||||
expect(body.get("requested_token_type")).toBe(TOKEN_TYPE_ID_JAG);
|
||||
expect(body.get("audience")).toBe(AS_ISSUER);
|
||||
expect(body.get("client_id")).toBe(EMA_MOCK_IDP_CLIENT_ID);
|
||||
expect(body.get("client_secret")).toBe(EMA_MOCK_IDP_CLIENT_SECRET);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: ID_JAG,
|
||||
issued_token_type: TOKEN_TYPE_ID_JAG,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
},
|
||||
);
|
||||
it("delegates to the SDK ID-JAG helper and returns the grant", async () => {
|
||||
mockDiscoverAndRequestJwtAuthGrant.mockResolvedValueOnce({
|
||||
jwtAuthGrant: ID_JAG,
|
||||
authorizationServerUrl: IDP_ISSUER,
|
||||
});
|
||||
|
||||
const idJag = await exchangeIdJag({
|
||||
idp: {
|
||||
|
|
@ -71,17 +75,33 @@ describe("ema wire", () => {
|
|||
},
|
||||
idToken: ID_TOKEN,
|
||||
audience: AS_ISSUER,
|
||||
fetchFn,
|
||||
resource: MCP_RESOURCE,
|
||||
scope: "mcp",
|
||||
});
|
||||
|
||||
expect(idJag).toBe(ID_JAG);
|
||||
expect(mockDiscoverAndRequestJwtAuthGrant).toHaveBeenCalledWith({
|
||||
idpUrl: IDP_ISSUER,
|
||||
audience: AS_ISSUER,
|
||||
resource: MCP_RESOURCE,
|
||||
idToken: ID_TOKEN,
|
||||
clientId: EMA_MOCK_IDP_CLIENT_ID,
|
||||
clientSecret: EMA_MOCK_IDP_CLIENT_SECRET,
|
||||
scope: "mcp",
|
||||
fetchFn: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when IdP metadata is missing a token_endpoint", async () => {
|
||||
// Canaries: call the real `discoverAndRequestJwtAuthGrant` so
|
||||
// `wire.ts` regex remaps stay tied to actual SDK error wording. Fabricating
|
||||
// those strings in mocks would hide SDK message drift (mapping would fall
|
||||
// through to the generic EMA leg 2 wrapper while tests still passed).
|
||||
it("maps real SDK missing-token-endpoint errors (canary)", async () => {
|
||||
await useRealDiscoverAndRequestJwtAuthGrant();
|
||||
const errorSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
// All discovery endpoints 404 → SDK returns undefined metadata.
|
||||
// All discovery endpoints 404 → SDK throws "Failed to discover token endpoint…".
|
||||
const fetchFn = vi.fn(
|
||||
async () => new Response("not found", { status: 404 }),
|
||||
);
|
||||
|
|
@ -95,13 +115,15 @@ describe("ema wire", () => {
|
|||
},
|
||||
idToken: ID_TOKEN,
|
||||
audience: AS_ISSUER,
|
||||
resource: MCP_RESOURCE,
|
||||
fetchFn,
|
||||
}),
|
||||
).rejects.toThrow(/IdP metadata missing token_endpoint/);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets resource and scope params and throws when no ID-JAG is returned", async () => {
|
||||
it("maps real SDK invalid token-exchange responses (canary)", async () => {
|
||||
await useRealDiscoverAndRequestJwtAuthGrant();
|
||||
const fetchFn = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
|
|
@ -110,9 +132,9 @@ describe("ema wire", () => {
|
|||
}
|
||||
if (url === `${IDP_ISSUER}/token`) {
|
||||
const body = new URLSearchParams(init?.body as string);
|
||||
expect(body.get("resource")).toBe("https://mcp.test/resource");
|
||||
expect(body.get("resource")).toBe(MCP_RESOURCE);
|
||||
expect(body.get("scope")).toBe("mcp profile");
|
||||
// 200 OK but empty access_token → ID-JAG missing branch.
|
||||
// 200 OK but invalid ID-JAG shape → SDK "Invalid token exchange response…".
|
||||
return new Response(JSON.stringify({ issued_token_type: "x" }));
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
|
|
@ -128,7 +150,7 @@ describe("ema wire", () => {
|
|||
},
|
||||
idToken: ID_TOKEN,
|
||||
audience: AS_ISSUER,
|
||||
resource: "https://mcp.test/resource",
|
||||
resource: MCP_RESOURCE,
|
||||
scope: "mcp profile",
|
||||
fetchFn,
|
||||
}),
|
||||
|
|
@ -136,17 +158,61 @@ describe("ema wire", () => {
|
|||
});
|
||||
|
||||
it("throws when IdP token exchange fails", async () => {
|
||||
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/.well-known/oauth-authorization-server")) {
|
||||
return new Response(JSON.stringify(idpMetadata()));
|
||||
}
|
||||
if (url === `${IDP_ISSUER}/token`) {
|
||||
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
mockDiscoverAndRequestJwtAuthGrant.mockRejectedValueOnce(
|
||||
new Error("invalid_grant"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
exchangeIdJag({
|
||||
idp: {
|
||||
issuer: IDP_ISSUER,
|
||||
clientId: EMA_MOCK_IDP_CLIENT_ID,
|
||||
clientSecret: EMA_MOCK_IDP_CLIENT_SECRET,
|
||||
},
|
||||
idToken: ID_TOKEN,
|
||||
audience: AS_ISSUER,
|
||||
resource: MCP_RESOURCE,
|
||||
}),
|
||||
).rejects.toThrow(/EMA leg 2/);
|
||||
});
|
||||
|
||||
it("rejects an empty resource identifier before calling the SDK", async () => {
|
||||
await expect(
|
||||
exchangeIdJag({
|
||||
idp: {
|
||||
issuer: IDP_ISSUER,
|
||||
clientId: EMA_MOCK_IDP_CLIENT_ID,
|
||||
clientSecret: EMA_MOCK_IDP_CLIENT_SECRET,
|
||||
},
|
||||
idToken: ID_TOKEN,
|
||||
audience: AS_ISSUER,
|
||||
resource: " ",
|
||||
}),
|
||||
).rejects.toThrow(/EMA leg 2 requires a resource identifier/);
|
||||
expect(mockDiscoverAndRequestJwtAuthGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps non-Error SDK failures through the generic EMA leg 2 wrapper", async () => {
|
||||
mockDiscoverAndRequestJwtAuthGrant.mockRejectedValueOnce("boom");
|
||||
|
||||
await expect(
|
||||
exchangeIdJag({
|
||||
idp: {
|
||||
issuer: IDP_ISSUER,
|
||||
clientId: EMA_MOCK_IDP_CLIENT_ID,
|
||||
clientSecret: EMA_MOCK_IDP_CLIENT_SECRET,
|
||||
},
|
||||
idToken: ID_TOKEN,
|
||||
audience: AS_ISSUER,
|
||||
resource: MCP_RESOURCE,
|
||||
}),
|
||||
).rejects.toThrow(/EMA leg 2 \(IdP token exchange for ID-JAG\): boom/);
|
||||
});
|
||||
|
||||
it("throws when the SDK returns success without a jwtAuthGrant", async () => {
|
||||
mockDiscoverAndRequestJwtAuthGrant.mockResolvedValueOnce({
|
||||
jwtAuthGrant: "",
|
||||
authorizationServerUrl: IDP_ISSUER,
|
||||
});
|
||||
|
||||
await expect(
|
||||
|
|
@ -158,9 +224,9 @@ describe("ema wire", () => {
|
|||
},
|
||||
idToken: ID_TOKEN,
|
||||
audience: AS_ISSUER,
|
||||
fetchFn,
|
||||
resource: MCP_RESOURCE,
|
||||
}),
|
||||
).rejects.toThrow(/EMA leg 2/);
|
||||
).rejects.toThrow(/did not return an ID-JAG/);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ import {
|
|||
formatOAuthFailureDetail,
|
||||
isUnauthorizedError,
|
||||
} from "@inspector/core/auth/utils.js";
|
||||
import {
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
UnauthorizedError,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
describe("parseHttpUrl", () => {
|
||||
|
|
@ -263,6 +268,36 @@ describe("isUnauthorizedError", () => {
|
|||
it("returns false for null", () => {
|
||||
expect(isUnauthorizedError(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for SDK UnauthorizedError", () => {
|
||||
expect(isUnauthorizedError(new UnauthorizedError("Unauthorized"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true when EraNegotiationFailed wraps UnauthorizedError in data.cause", () => {
|
||||
const wrapped = new SdkError(
|
||||
SdkErrorCode.EraNegotiationFailed,
|
||||
"Version negotiation probe failed",
|
||||
{ cause: new UnauthorizedError("Unauthorized") },
|
||||
);
|
||||
expect(isUnauthorizedError(wrapped)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when UnauthorizedError is on native Error.cause", () => {
|
||||
const wrapped = new Error("probe failed", {
|
||||
cause: new UnauthorizedError("Unauthorized"),
|
||||
});
|
||||
expect(isUnauthorizedError(wrapped)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unrelated SdkError", () => {
|
||||
expect(
|
||||
isUnauthorizedError(
|
||||
new SdkError(SdkErrorCode.RequestTimeout, "timed out"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthErrorDescription without description/uri", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue