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
28 changed files with 1513 additions and 852 deletions
|
|
@ -29,7 +29,7 @@ const oauthTestStatePath = join(
|
|||
|
||||
async function completeOAuthAuthorization(
|
||||
authorizationUrl: URL,
|
||||
): Promise<string> {
|
||||
): Promise<{ code: string; iss?: string }> {
|
||||
let response = await fetch(authorizationUrl.toString(), {
|
||||
redirect: "manual",
|
||||
});
|
||||
|
|
@ -62,19 +62,23 @@ async function completeOAuthAuthorization(
|
|||
if (!code) {
|
||||
throw new Error("Missing authorization code");
|
||||
}
|
||||
return code;
|
||||
const iss = redirect.searchParams.get("iss") ?? undefined;
|
||||
return iss ? { code, iss } : { code };
|
||||
}
|
||||
|
||||
function createAutoCompleteNavigation(
|
||||
redirectUrlProvider: MutableRedirectUrlProvider,
|
||||
) {
|
||||
return new CallbackNavigation(async (url) => {
|
||||
const code = await completeOAuthAuthorization(url);
|
||||
const { code, iss } = await completeOAuthAuthorization(url);
|
||||
const redirect = redirectUrlProvider.redirectUrl;
|
||||
if (!redirect) {
|
||||
throw new Error("redirectUrl not set");
|
||||
}
|
||||
await fetch(`${redirect}?code=${encodeURIComponent(code)}`);
|
||||
const callback = new URL(redirect);
|
||||
callback.searchParams.set("code", code);
|
||||
if (iss) callback.searchParams.set("iss", iss);
|
||||
await fetch(callback.href);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@inspector/core/*": ["../../core/*"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"jsx": "react-jsx",
|
||||
"outDir": "./build",
|
||||
"rootDir": ".",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@inspector/core/*": ["../../core/*"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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/);
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -136,16 +136,23 @@ export async function createClientMetadataServer(
|
|||
});
|
||||
}
|
||||
|
||||
/** Result of approving consent on the local composable test AS. */
|
||||
export interface CompletedOAuthAuthorization {
|
||||
code: string;
|
||||
/** RFC 9207 `iss` from the redirect when the AS returns it. */
|
||||
iss?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatically complete OAuth against the local composable test AS.
|
||||
* GET shows an HTML consent page; approve via POST (same as CLI test helper).
|
||||
*
|
||||
* @param authorizationUrl - The authorization URL from oauthAuthorizationRequired event
|
||||
* @returns Authorization code extracted from redirect URL
|
||||
* @returns Authorization code and optional RFC 9207 `iss` from the redirect
|
||||
*/
|
||||
export async function completeOAuthAuthorization(
|
||||
authorizationUrl: URL,
|
||||
): Promise<string> {
|
||||
): Promise<CompletedOAuthAuthorization> {
|
||||
let response = await fetch(authorizationUrl.toString(), {
|
||||
redirect: "manual",
|
||||
});
|
||||
|
|
@ -180,5 +187,6 @@ export async function completeOAuthAuthorization(
|
|||
throw new Error(`No authorization code in redirect URL: ${redirectUrl}`);
|
||||
}
|
||||
|
||||
return code;
|
||||
const iss = redirectUrlObj.searchParams.get("iss") ?? undefined;
|
||||
return iss ? { code, iss } : { code };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,7 +124,10 @@ describe("InspectorClient direct mid-session OAuth", () => {
|
|||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
{
|
||||
const { code, iss } = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(code, iss);
|
||||
}
|
||||
await client.connect();
|
||||
|
||||
const tokens = await client.getOAuthTokens();
|
||||
|
|
@ -205,7 +208,10 @@ describe("InspectorClient direct mid-session OAuth", () => {
|
|||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
{
|
||||
const { code, iss } = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(code, iss);
|
||||
}
|
||||
await client.connect();
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
|
|
@ -227,10 +233,9 @@ describe("InspectorClient direct mid-session OAuth", () => {
|
|||
"weather:read",
|
||||
);
|
||||
|
||||
const stepUpCode = await completeOAuthAuthorization(
|
||||
recovery!.authorizationUrl,
|
||||
);
|
||||
await client.completeOAuthFlow(stepUpCode);
|
||||
const { code: stepUpCode, iss: stepUpCodeIss } =
|
||||
await completeOAuthAuthorization(recovery!.authorizationUrl);
|
||||
await client.completeOAuthFlow(stepUpCode, stepUpCodeIss);
|
||||
|
||||
const result = await client.callTool(getTempTool!, {
|
||||
city: "NYC",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
getDCRRequests,
|
||||
invalidateAccessToken,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/client";
|
||||
import { flushStoreFileWrites } from "@inspector/core/storage/store-io.js";
|
||||
import {
|
||||
createOAuthClientConfig,
|
||||
|
|
@ -180,8 +181,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
expect(stateAfterAuth?.oauthClientInfo).toBeDefined();
|
||||
expect(stateAfterAuth?.oauthClientInfo?.client_id).toBe(staticClientId);
|
||||
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode, authCodeIss);
|
||||
await client.connect();
|
||||
|
||||
const stateAfterComplete = client.getOAuthFlowState();
|
||||
|
|
@ -260,8 +262,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
expect(authUrl.href).toContain("/oauth/authorize");
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode, authCodeIss);
|
||||
await client.connect();
|
||||
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
|
|
@ -354,8 +357,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
expect(authUrl.href).toContain("/oauth/authorize");
|
||||
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode, authCodeIss);
|
||||
await client.connect();
|
||||
|
||||
// Verify tokens are stored
|
||||
|
|
@ -430,8 +434,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode, authCodeIss);
|
||||
await client.connect();
|
||||
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
|
|
@ -498,8 +503,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
expect(stateAfterAuth?.oauthClientInfo).toBeDefined();
|
||||
expect(stateAfterAuth?.oauthClientInfo?.client_id).toBeDefined();
|
||||
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode, authCodeIss);
|
||||
await client.connect();
|
||||
|
||||
const stateAfterComplete = client.getOAuthFlowState();
|
||||
|
|
@ -567,8 +573,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode, authCodeIss);
|
||||
await client.connect();
|
||||
|
||||
const dcr = getDCRRequests();
|
||||
|
|
@ -624,8 +631,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
|
||||
const authUrlFirst = await client.authenticate();
|
||||
if (!authUrlFirst) throw new Error("Expected authorization URL");
|
||||
const authCodeFirst = await completeOAuthAuthorization(authUrlFirst);
|
||||
await client.completeOAuthFlow(authCodeFirst);
|
||||
const { code: authCodeFirst, iss: authCodeFirstIss } =
|
||||
await completeOAuthAuthorization(authUrlFirst);
|
||||
await client.completeOAuthFlow(authCodeFirst, authCodeFirstIss);
|
||||
await client.connect();
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
|
||||
|
|
@ -634,8 +642,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
|
||||
const authUrlSecond = await client.authenticate();
|
||||
if (!authUrlSecond) throw new Error("Expected authorization URL");
|
||||
const authCodeSecond = await completeOAuthAuthorization(authUrlSecond);
|
||||
await client.completeOAuthFlow(authCodeSecond);
|
||||
const { code: authCodeSecond, iss: authCodeSecondIss } =
|
||||
await completeOAuthAuthorization(authUrlSecond);
|
||||
await client.completeOAuthFlow(authCodeSecond, authCodeSecondIss);
|
||||
await client.connect();
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
});
|
||||
|
|
@ -836,8 +845,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode, authCodeIss);
|
||||
|
||||
await client.connect();
|
||||
expect(await client.isOAuthAuthorized()).toBe(true);
|
||||
|
|
@ -906,8 +916,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode, authCodeIss);
|
||||
await client.connect();
|
||||
|
||||
const tokens = await client.getOAuthTokens();
|
||||
|
|
@ -986,8 +997,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode, authCodeIss);
|
||||
await client.connect();
|
||||
|
||||
const tokens = await client.getOAuthTokens();
|
||||
|
|
@ -1065,8 +1077,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
try {
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode, authCodeIss);
|
||||
await client.connect();
|
||||
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
|
|
@ -1181,8 +1194,9 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
expect(authUrl.href).toContain("/oauth/authorize");
|
||||
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode, authCodeIss);
|
||||
await client.connect();
|
||||
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
|
|
@ -1196,6 +1210,19 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
);
|
||||
expect(oauthUrls.length).toBeGreaterThan(0);
|
||||
|
||||
// SEP-2351: discovery uses stable well-known suffixes (not only "some oauth URL").
|
||||
const trackedUrls = tracker.map((c) => c.url);
|
||||
expect(
|
||||
trackedUrls.some((u) =>
|
||||
u.includes("/.well-known/oauth-protected-resource"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
trackedUrls.some((u) =>
|
||||
u.includes("/.well-known/oauth-authorization-server"),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Verify fetch tracking categories: auth vs transport
|
||||
const fetchRequests = fetchRequestLogState.getFetchRequests();
|
||||
const authFetches = fetchRequests.filter((r) => r.category === "auth");
|
||||
|
|
@ -1206,4 +1233,269 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
expect(transportFetches.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SEP-2468 iss validation (Streamable HTTP)", () => {
|
||||
const transport = transports[1]!;
|
||||
|
||||
async function startStaticClient() {
|
||||
const staticClientId = "test-iss-client";
|
||||
const staticClientSecret = "test-iss-secret";
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: transport.serverType,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
server = new TestServerHttp(serverConfig);
|
||||
const port = await server.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: transport.clientType,
|
||||
url: `${serverUrl}${transport.endpoint}`,
|
||||
} as MCPServerConfig,
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
},
|
||||
);
|
||||
return { serverUrl, oauthConfig };
|
||||
}
|
||||
|
||||
it("accepts completeOAuthFlow with matching callback iss", async () => {
|
||||
await startStaticClient();
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const { code, iss } = await completeOAuthAuthorization(authUrl);
|
||||
expect(iss).toBeDefined();
|
||||
await client.completeOAuthFlow(code, iss);
|
||||
await client.connect();
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
expect(await client.getOAuthTokens()).toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects mismatched callback iss before storing tokens", async () => {
|
||||
await startStaticClient();
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const { code } = await completeOAuthAuthorization(authUrl);
|
||||
await expect(
|
||||
client.completeOAuthFlow(code, "https://evil.example"),
|
||||
).rejects.toThrow(/Issuer mismatch|issuer/i);
|
||||
expect(await client.getOAuthTokens()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects missing iss when AS requires authorization_response iss", async () => {
|
||||
await startStaticClient();
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const { code } = await completeOAuthAuthorization(authUrl);
|
||||
await expect(client.completeOAuthFlow(code)).rejects.toThrow(
|
||||
/Issuer mismatch|issuer/i,
|
||||
);
|
||||
expect(await client.getOAuthTokens()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SEP-2207 offline_access on authorize (Streamable HTTP)", () => {
|
||||
it("requests offline_access when AS scopes_supported includes it", async () => {
|
||||
const transport = transports[1]!;
|
||||
const staticClientId = "test-offline-access-client";
|
||||
const staticClientSecret = "test-offline-access-secret";
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: transport.serverType,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportRefreshTokens: true,
|
||||
scopesSupported: ["mcp", "offline_access"],
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
server = new TestServerHttp(serverConfig);
|
||||
const port = await server.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
scope: "mcp",
|
||||
});
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: transport.clientType,
|
||||
url: `${serverUrl}${transport.endpoint}`,
|
||||
} as MCPServerConfig,
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const scope = authUrl.searchParams.get("scope") ?? "";
|
||||
expect(scope.split(/\s+/)).toContain("offline_access");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SEP-2352 AS migration (Streamable HTTP)", () => {
|
||||
it("does not silently reuse issuer-A tokens when discovery resolves issuer B", async () => {
|
||||
const transport = transports[1]!;
|
||||
const staticClientId = "test-as-migrate-client";
|
||||
const staticClientSecret = "test-as-migrate-secret";
|
||||
|
||||
let asB: TestServerHttp | undefined;
|
||||
try {
|
||||
const serverAConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: transport.serverType,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
server = new TestServerHttp(serverAConfig);
|
||||
const portA = await server.start();
|
||||
const serverAUrl = `http://localhost:${portA}`;
|
||||
await waitForOAuthWellKnown(serverAUrl);
|
||||
|
||||
const serverBConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: transport.serverType,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
asB = new TestServerHttp(serverBConfig);
|
||||
const portB = await asB.start();
|
||||
const serverBUrl = `http://localhost:${portB}`;
|
||||
await waitForOAuthWellKnown(serverBUrl);
|
||||
const issuerB = serverBUrl.replace(/\/$/, "");
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: transport.clientType,
|
||||
url: `${serverAUrl}${transport.endpoint}`,
|
||||
} as MCPServerConfig,
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const mcpUrl = `${serverAUrl}${transport.endpoint}`;
|
||||
const authUrlA = await client.authenticate();
|
||||
if (!authUrlA) throw new Error("Expected authorization URL for A");
|
||||
expect(authUrlA.origin).toBe(new URL(serverAUrl).origin);
|
||||
const { code, iss } = await completeOAuthAuthorization(authUrlA);
|
||||
await client.completeOAuthFlow(code, iss);
|
||||
await client.connect();
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
expect(await client.getOAuthTokens()).toBeDefined();
|
||||
|
||||
await client.disconnect();
|
||||
|
||||
// SDK auth() reuses cached authorizationServerUrl and will not pick up a
|
||||
// PRM flip until discovery is replaced. Simulate AS migration by pointing
|
||||
// persisted discovery at issuer B while leaving issuer-A tokens in place.
|
||||
const asBMetadata = await discoverAuthorizationServerMetadata(issuerB);
|
||||
await oauthConfig.storage.saveDiscoveryState(mcpUrl, {
|
||||
authorizationServerUrl: issuerB,
|
||||
authorizationServerMetadata: asBMetadata,
|
||||
resourceMetadata: {
|
||||
resource: `${serverAUrl}/`,
|
||||
authorization_servers: [issuerB],
|
||||
},
|
||||
});
|
||||
// Shared in-memory AS token store would otherwise let B redeem A's
|
||||
// refresh_token; clear AS-side grants so reuse cannot succeed via refresh.
|
||||
clearOAuthTestData();
|
||||
|
||||
const authUrlB = await client.authenticate();
|
||||
expect(authUrlB).toBeDefined();
|
||||
expect(authUrlB!.origin).toBe(new URL(serverBUrl).origin);
|
||||
} finally {
|
||||
if (asB) await asB.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -191,7 +191,10 @@ describe("InspectorClient remote mid-session OAuth", () => {
|
|||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
{
|
||||
const { code, iss } = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(code, iss);
|
||||
}
|
||||
await client.connect();
|
||||
|
||||
const tokens = await client.getOAuthTokens();
|
||||
|
|
@ -273,7 +276,10 @@ describe("InspectorClient remote mid-session OAuth", () => {
|
|||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
{
|
||||
const { code, iss } = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(code, iss);
|
||||
}
|
||||
await client.connect();
|
||||
|
||||
const tokens = await client.getOAuthTokens();
|
||||
|
|
@ -361,7 +367,10 @@ describe("InspectorClient remote mid-session OAuth", () => {
|
|||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
{
|
||||
const { code, iss } = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(code, iss);
|
||||
}
|
||||
await client.connect();
|
||||
|
||||
const toolsResult = await client.listTools();
|
||||
|
|
@ -396,9 +405,8 @@ describe("InspectorClient remote mid-session OAuth", () => {
|
|||
"weather:read",
|
||||
);
|
||||
|
||||
const stepUpCode = await completeOAuthAuthorization(
|
||||
recovery!.authorizationUrl,
|
||||
);
|
||||
const { code: stepUpCode, iss: stepUpCodeIss } =
|
||||
await completeOAuthAuthorization(recovery!.authorizationUrl);
|
||||
|
||||
// Mirror `/oauth/callback`: new InspectorClient instance, same persisted OAuth
|
||||
// storage, reattach to the live remote backend session.
|
||||
|
|
@ -409,7 +417,10 @@ describe("InspectorClient remote mid-session OAuth", () => {
|
|||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
await callbackClient.resumeAfterOAuth(stepUpCode, { remoteSessionId });
|
||||
await callbackClient.resumeAfterOAuth(stepUpCode, {
|
||||
iss: stepUpCodeIss,
|
||||
remoteSessionId,
|
||||
});
|
||||
|
||||
expect(callbackClient.getStatus()).toBe("connected");
|
||||
|
||||
|
|
@ -505,15 +516,17 @@ describe("InspectorClient remote mid-session OAuth", () => {
|
|||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const initialCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(initialCode);
|
||||
const { code: initialCode, iss: initialCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(initialCode, initialCodeIss);
|
||||
await client.connect();
|
||||
|
||||
const mcpServerUrl = `${serverUrl}/mcp`;
|
||||
await oauthConfig.storage.clearTokens(mcpServerUrl);
|
||||
const reauthUrl = await client.authenticate();
|
||||
if (!reauthUrl) throw new Error("Expected reauth URL");
|
||||
const code = await completeOAuthAuthorization(reauthUrl);
|
||||
const { code: code, iss: codeIss } =
|
||||
await completeOAuthAuthorization(reauthUrl);
|
||||
|
||||
const callbackClient = new InspectorClient(
|
||||
{
|
||||
|
|
@ -524,6 +537,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
|
|||
);
|
||||
const connectSpy = vi.spyOn(callbackClient, "connect");
|
||||
await callbackClient.resumeAfterOAuth(code, {
|
||||
iss: codeIss,
|
||||
remoteSessionId: "dead-remote-session-id",
|
||||
});
|
||||
expect(connectSpy).toHaveBeenCalled();
|
||||
|
|
@ -603,7 +617,10 @@ describe("InspectorClient remote mid-session OAuth", () => {
|
|||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
{
|
||||
const { code, iss } = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(code, iss);
|
||||
}
|
||||
await client.connect();
|
||||
|
||||
const tokens = await client.getOAuthTokens();
|
||||
|
|
@ -693,7 +710,10 @@ describe("InspectorClient remote mid-session OAuth", () => {
|
|||
|
||||
const authUrl = await client.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl));
|
||||
{
|
||||
const { code, iss } = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(code, iss);
|
||||
}
|
||||
await client.connect();
|
||||
|
||||
const tokens = await client.getOAuthTokens();
|
||||
|
|
|
|||
|
|
@ -248,8 +248,9 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => {
|
|||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
expect(authUrl.href).toContain("/oauth/authorize");
|
||||
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode, authCodeIss);
|
||||
await client.connect();
|
||||
|
||||
// Verify tokens are stored
|
||||
|
|
@ -337,8 +338,9 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => {
|
|||
|
||||
const authUrl = await client1.authenticate();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client1.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client1.completeOAuthFlow(authCode, authCodeIss);
|
||||
await client1.connect();
|
||||
|
||||
const tokens1 = await client1.getOAuthTokens();
|
||||
|
|
@ -504,8 +506,9 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => {
|
|||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
expect(authUrl.href).toContain("/oauth/authorize");
|
||||
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode, authCodeIss);
|
||||
await client.connect();
|
||||
|
||||
// Verify tokens are stored
|
||||
|
|
|
|||
|
|
@ -493,8 +493,9 @@ describe("InspectorClient OAuth", () => {
|
|||
// connect() creates transport with authProvider; tokens are already in storage.
|
||||
const authorizationUrl = await testClient.authenticate();
|
||||
if (!authorizationUrl) throw new Error("Expected authorization URL");
|
||||
const authCode = await completeOAuthAuthorization(authorizationUrl);
|
||||
await testClient.completeOAuthFlow(authCode);
|
||||
const { code: authCode, iss: authCodeIss } =
|
||||
await completeOAuthAuthorization(authorizationUrl);
|
||||
await testClient.completeOAuthFlow(authCode, authCodeIss);
|
||||
|
||||
await testClient.connect();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
UnauthorizedError,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import {
|
||||
OAUTH_CALLBACK_PATH,
|
||||
OAUTH_PENDING_SERVER_KEY,
|
||||
|
|
@ -65,4 +70,14 @@ describe("isUnauthorizedError", () => {
|
|||
expect(isUnauthorizedError(undefined)).toBe(false);
|
||||
expect(isUnauthorizedError(42)).toBe(false);
|
||||
});
|
||||
|
||||
it("unwraps EraNegotiationFailed → UnauthorizedError (data.cause)", () => {
|
||||
expect(
|
||||
isUnauthorizedError(
|
||||
new SdkError(SdkErrorCode.EraNegotiationFailed, "probe failed", {
|
||||
cause: new UnauthorizedError("Unauthorized"),
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,8 +29,10 @@
|
|||
* during `tsc -b`. We point at the @types declaration packages so the
|
||||
* imports type-check; Vite's `dedupe` handles the runtime resolution —
|
||||
* see vite.config.ts.
|
||||
*
|
||||
* Paths are relative to this config file (no `baseUrl` — deprecated in
|
||||
* TypeScript 6 / removed in 7).
|
||||
*/
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@inspector/core/*": ["../../core/*"],
|
||||
"react": ["./node_modules/@types/react"],
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@
|
|||
* start-vite-dev-server.ts) live in src/ and import from @inspector/core/*;
|
||||
* tsc follows those transitive imports while type-checking vite.config.ts,
|
||||
* so this tsconfig needs the same alias tsconfig.app.json has.
|
||||
*
|
||||
* Paths are relative to this config file (no `baseUrl` — deprecated in
|
||||
* TypeScript 6 / removed in 7).
|
||||
*/
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@inspector/core/*": ["../../core/*"],
|
||||
"pino": ["./node_modules/pino"],
|
||||
|
|
|
|||
|
|
@ -67,11 +67,13 @@ export async function mintEmaResourceTokens(
|
|||
}
|
||||
|
||||
const audience = ctx.resourceAsUrl.href.replace(/\/$/, "");
|
||||
// SDK leg-2 helper requires `resource` on the wire; fall back to PRM `resource`.
|
||||
const resource = ctx.resourceUrl?.href ?? ctx.resourceMetadata.resource;
|
||||
const idJag = await exchangeIdJag({
|
||||
idp: config.idp,
|
||||
idToken,
|
||||
audience,
|
||||
resource: ctx.resourceUrl?.href,
|
||||
resource,
|
||||
scope: ctx.scope,
|
||||
fetchFn: config.fetchFn,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/client";
|
||||
import { discoverAndRequestJwtAuthGrant } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { OAuthTokensSchema } from "@modelcontextprotocol/core";
|
||||
import type { EnterpriseManagedAuthIdpConfig } from "../../client/types.js";
|
||||
import {
|
||||
GRANT_TYPE_JWT_BEARER,
|
||||
GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
TOKEN_TYPE_ID_JAG,
|
||||
TOKEN_TYPE_ID_TOKEN,
|
||||
} from "./constants.js";
|
||||
import { GRANT_TYPE_JWT_BEARER } from "./constants.js";
|
||||
import { parseHttpUrl } from "../utils.js";
|
||||
import { discoverResourceAsMetadata } from "./resourceContext.js";
|
||||
import { normalizeIdpIssuer } from "./storage.js";
|
||||
|
|
@ -19,70 +14,64 @@ import {
|
|||
postOAuthTokenRequest,
|
||||
} from "./tokenEndpoint.js";
|
||||
|
||||
/** Leg 2 — exchange ID Token for ID-JAG at the enterprise IdP (RFC 8693). */
|
||||
/**
|
||||
* Leg 2 — exchange ID Token for ID-JAG at the enterprise IdP (RFC 8693).
|
||||
*
|
||||
* Delegates to SDK `discoverAndRequestJwtAuthGrant`. `resource` is required
|
||||
* (the SDK always sends it on the wire). Callers should pass
|
||||
* `resourceUrl ?? resourceMetadata.resource` from EMA resource context discovery.
|
||||
*/
|
||||
export async function exchangeIdJag(params: {
|
||||
idp: EnterpriseManagedAuthIdpConfig;
|
||||
idToken: string;
|
||||
audience: string;
|
||||
resource?: string;
|
||||
/** RFC 8707 resource indicator — required by the SDK Layer-2 helper. */
|
||||
resource: string;
|
||||
scope?: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<string> {
|
||||
const issuer = normalizeIdpIssuer(params.idp.issuer);
|
||||
const issuerUrl = parseHttpUrl(issuer, "EMA IdP issuer (Client Settings)");
|
||||
const idpMetadata = await discoverAuthorizationServerMetadata(issuerUrl, {
|
||||
const resource = params.resource.trim();
|
||||
if (!resource) {
|
||||
throw new Error("EMA leg 2 requires a resource identifier");
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<typeof discoverAndRequestJwtAuthGrant>>;
|
||||
try {
|
||||
result = await discoverAndRequestJwtAuthGrant({
|
||||
idpUrl: issuer,
|
||||
audience: params.audience,
|
||||
resource,
|
||||
idToken: params.idToken,
|
||||
clientId: params.idp.clientId,
|
||||
clientSecret: params.idp.clientSecret,
|
||||
scope: params.scope,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
if (!idpMetadata?.token_endpoint) {
|
||||
throw new Error("IdP metadata missing token_endpoint");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Drift-sensitive: `@modelcontextprotocol/client` currently throws plain
|
||||
// `Error`s (not typed SdkError codes) for these failure modes. Remap to
|
||||
// stable Inspector UX strings by matching SDK wording. Prefer typed errors
|
||||
// when the SDK grows them. Canaries in `wire.test.ts` call the real helper
|
||||
// so a wording change fails CI instead of silently falling through.
|
||||
if (/Failed to discover token endpoint/i.test(message)) {
|
||||
throw new Error("IdP metadata missing token_endpoint", { cause: err });
|
||||
}
|
||||
|
||||
const clientInformation = {
|
||||
client_id: params.idp.clientId,
|
||||
client_secret: params.idp.clientSecret,
|
||||
token_endpoint_auth_method: "client_secret_post",
|
||||
} as OAuthClientInformation;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
requested_token_type: TOKEN_TYPE_ID_JAG,
|
||||
subject_token: params.idToken,
|
||||
subject_token_type: TOKEN_TYPE_ID_TOKEN,
|
||||
audience: params.audience,
|
||||
if (/Invalid token exchange response/i.test(message)) {
|
||||
throw new Error("IdP token exchange did not return an ID-JAG", {
|
||||
cause: err,
|
||||
});
|
||||
if (params.resource) {
|
||||
body.set("resource", params.resource);
|
||||
}
|
||||
if (params.scope) {
|
||||
body.set("scope", params.scope);
|
||||
throw new Error(`EMA leg 2 (IdP token exchange for ID-JAG): ${message}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await postOAuthTokenRequest(
|
||||
parseHttpUrl(
|
||||
idpMetadata.token_endpoint,
|
||||
"IdP token_endpoint (from OIDC discovery)",
|
||||
),
|
||||
body,
|
||||
idpMetadata,
|
||||
clientInformation,
|
||||
params.fetchFn,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseOAuthTokenErrorResponse(
|
||||
response,
|
||||
"EMA leg 2 (IdP token exchange for ID-JAG)",
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
access_token?: string;
|
||||
issued_token_type?: string;
|
||||
};
|
||||
const idJag = json.access_token;
|
||||
if (!idJag) {
|
||||
if (!result.jwtAuthGrant) {
|
||||
throw new Error("IdP token exchange did not return an ID-JAG");
|
||||
}
|
||||
return idJag;
|
||||
return result.jwtAuthGrant;
|
||||
}
|
||||
|
||||
/** Leg 3 — redeem ID-JAG for MCP resource access token (RFC 7523). */
|
||||
|
|
|
|||
|
|
@ -1,46 +1,22 @@
|
|||
/**
|
||||
* SEP-2350 scope helpers aligned with v2 `@modelcontextprotocol/client` exports.
|
||||
* On SDK v2 upgrade, delete this module and import `computeScopeUnion` /
|
||||
* `isStrictScopeSuperset` from the client package instead.
|
||||
* SEP-2350 scope helpers. Union / strict-superset algorithms come from
|
||||
* `@modelcontextprotocol/client`; Inspector-only persistence helpers stay local.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseScopeString,
|
||||
unionAuthorizationScopes,
|
||||
} from "./challenge.js";
|
||||
computeScopeUnion as sdkComputeScopeUnion,
|
||||
isStrictScopeSuperset as sdkIsStrictScopeSuperset,
|
||||
} from "@modelcontextprotocol/client";
|
||||
|
||||
/** Union space-delimited scope strings (order-preserving, deduped). */
|
||||
export function computeScopeUnion(
|
||||
...scopes: ReadonlyArray<string | undefined>
|
||||
): string | undefined {
|
||||
let merged: string | undefined;
|
||||
for (const scope of scopes) {
|
||||
merged = unionAuthorizationScopes(merged, parseScopeString(scope)).join(
|
||||
" ",
|
||||
);
|
||||
if (!merged) {
|
||||
merged = undefined;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
export const computeScopeUnion = sdkComputeScopeUnion;
|
||||
|
||||
/**
|
||||
* Whether `union` contains a scope token not present in `current`.
|
||||
* When the AS omits `scope` on the token response, `current` is empty and any
|
||||
* non-empty union is a strict superset — step-up must re-authorize, not refresh.
|
||||
*/
|
||||
export function isStrictScopeSuperset(
|
||||
union: string | undefined,
|
||||
current: string | undefined,
|
||||
): boolean {
|
||||
if (!union) return false;
|
||||
const currentSet = new Set((current ?? "").split(/\s+/).filter(Boolean));
|
||||
for (const token of union.split(/\s+/)) {
|
||||
if (token && !currentSet.has(token)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export const isStrictScopeSuperset = sdkIsStrictScopeSuperset;
|
||||
|
||||
/**
|
||||
* Scope to persist after a successful token grant (RFC 6749 §5.1).
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { UnauthorizedError } from "@modelcontextprotocol/client";
|
||||
import type { CallbackParams } from "./types.js";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
|
|
@ -172,13 +173,50 @@ export const generateOAuthErrorDescription = (
|
|||
* transport preserves the status on the error object; as a fallback, match
|
||||
* transport wording `"failed …(401)"` so unrelated `(401)` in messages does
|
||||
* not trigger OAuth.
|
||||
*
|
||||
* Under `protocolEra: auto|modern`, a negotiation-probe 401 can surface as
|
||||
* `SdkError(EraNegotiationFailed)` with the real {@link UnauthorizedError} at
|
||||
* `error.data.cause` (and sometimes native `error.cause`). Walk that chain so
|
||||
* OAuth recovery still starts.
|
||||
*/
|
||||
export function isUnauthorizedError(err: unknown): boolean {
|
||||
if (typeof err === "object" && err !== null) {
|
||||
const status = (err as { status?: number; code?: number }).status;
|
||||
const code = (err as { code?: number }).code;
|
||||
return isUnauthorizedErrorDeep(err, new Set());
|
||||
}
|
||||
|
||||
function isUnauthorizedErrorDeep(err: unknown, seen: Set<unknown>): boolean {
|
||||
if (err == null) return false;
|
||||
if (typeof err !== "object") {
|
||||
return /\bfailed\b[^\n]*\(401\)/i.test(String(err));
|
||||
}
|
||||
if (seen.has(err)) return false;
|
||||
seen.add(err);
|
||||
|
||||
if (UnauthorizedError.isInstance(err)) return true;
|
||||
|
||||
const status = (err as { status?: number }).status;
|
||||
const code = (err as { code?: unknown }).code;
|
||||
if (status === 401 || code === 401) return true;
|
||||
|
||||
if (err instanceof Error && /\bfailed\b[^\n]*\(401\)/i.test(err.message)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
"cause" in err &&
|
||||
isUnauthorizedErrorDeep((err as { cause: unknown }).cause, seen)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const data = (err as { data?: unknown }).data;
|
||||
if (
|
||||
data !== null &&
|
||||
typeof data === "object" &&
|
||||
"cause" in data &&
|
||||
isUnauthorizedErrorDeep((data as { cause: unknown }).cause, seen)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return /\bfailed\b[^\n]*\(401\)/i.test(message);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
# Inspector V2 Authorization - Specification
|
||||
|
||||
### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | V2 Auth | [V2 New Spec Impact](v2_new_spec_impact.md)
|
||||
#### Overview | [EMA / XAA](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md)
|
||||
|
||||
#### Overview | [EMA / XAA](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md) | [SDK consolidation](v2_auth_sdk_consolidation.md)
|
||||
|
||||
Inspector v2's authorization support spans OAuth 2.1 connect-time flows, Enterprise-Managed Authorization (EMA / XAA), mid-session credential recovery, and the hardening required by the MCP `2026-07-28` specification. This page is the landing point for that work; each area has its own spec below.
|
||||
|
||||
Inspector already ships connect-time OAuth and EMA infrastructure (`core/auth/`, `core/mcp/oauthManager.ts`, per-server OAuth fields in `~/.mcp-inspector/mcp.json` — see [Servers file](v2_servers_file.md)). The specs here cover extending that foundation across web, TUI, and CLI.
|
||||
Inspector already ships connect-time OAuth and EMA infrastructure (`core/auth/`, `core/mcp/oauthManager.ts`, per-server OAuth fields in `~/.mcp-inspector/mcp.json` — see [Servers file](v2_servers_file.md)). The specs here cover extending that foundation across web, TUI, and CLI. How that stack maps onto `@modelcontextprotocol/client` after the v2 upgrade is recorded in [SDK consolidation](v2_auth_sdk_consolidation.md).
|
||||
|
||||
## Authorization specs
|
||||
|
||||
|
|
@ -15,7 +16,7 @@ Support for [Enterprise-Managed Authorization](https://modelcontextprotocol.io/e
|
|||
|
||||
### [Hardening](v2_auth_hardening.md)
|
||||
|
||||
Aligns Inspector with the six authorization SEPs in the MCP `2026-07-28` release candidate — all OAuth client requirements (`iss` validation per RFC 9207, `application_type` in DCR, credentials bound to AS issuer, and more). Strategy: upgrade to the v2 TypeScript SDK (`@modelcontextprotocol/client`) and wire the gaps rather than reimplementing SDK OAuth logic. Covers connect-time and storage hardening that mid-session builds on.
|
||||
As-built status for the six MCP `2026-07-28` authorization SEPs ([#1527](https://github.com/modelcontextprotocol/inspector/issues/1527)): what is done (and how), per-SEP automated coverage, and remaining test/era-aware follow-ups.
|
||||
|
||||
### [Mid-session](v2_auth_mid_session.md)
|
||||
|
||||
|
|
@ -24,3 +25,7 @@ Detecting when in-flight MCP traffic needs new or elevated credentials, respondi
|
|||
### [Smoke testing](v2_auth_smoke_testing.md)
|
||||
|
||||
Manual smoke procedures for exercising Inspector OAuth against **hosted** MCP servers, complementing the automated integration coverage. Records known-good real endpoints, which client-ID mechanism each server supports, and how to configure Inspector (web, TUI, or CLI) via install-level `client.json`.
|
||||
|
||||
### [SDK consolidation](v2_auth_sdk_consolidation.md)
|
||||
|
||||
As-built: what auth logic moved onto the v2 SDK, what stayed Inspector-owned (and why), and which small SDK helper gaps would unlock further deletions.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# Inspector V2 Auth — Enterprise-Managed Authorization (EMA / XAA)
|
||||
|
||||
### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | V2 Auth | [V2 New Spec Impact](v2_new_spec_impact.md)
|
||||
#### [Overview](v2_auth.md) | EMA / XAA | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md)
|
||||
|
||||
#### [Overview](v2_auth.md) | EMA / XAA | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md) | [SDK consolidation](v2_auth_sdk_consolidation.md)
|
||||
|
||||
Tracks [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509).
|
||||
|
||||
|
|
@ -48,10 +49,10 @@ _Audited June 2026 against the [EMA extension spec](https://modelcontextprotocol
|
|||
Inspector depends on **`@modelcontextprotocol/sdk` v1.x** only (`^1.29.0` in root `package.json`). Standard OAuth and EMA both build on that package — there is **no** `@modelcontextprotocol/client` v2 dependency in the tree today.
|
||||
|
||||
| Concern | Package / module |
|
||||
| ------- | ---------------- |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Standard OAuth | v1 `@modelcontextprotocol/sdk/client/auth.js` via `BaseOAuthClientProvider` (`core/auth/providers.ts`) |
|
||||
| EMA leg 1 (IdP OIDC) | v1 SDK: `startAuthorization`, `exchangeAuthorization`, `discoverAuthorizationServerMetadata` — `core/auth/ema/idpOidc.ts` |
|
||||
| EMA legs 2–3 (ID-JAG + resource token) | **Local wire** in `core/auth/ema/wire.ts` (RFC 8693 token exchange + RFC 7523 JWT bearer); reuses v1 SDK for AS discovery, `selectClientAuthMethod`, `OAuthTokensSchema` |
|
||||
| EMA legs 2–3 (ID-JAG + resource token) | **Leg 2:** SDK `discoverAndRequestJwtAuthGrant` via `exchangeIdJag` in `core/auth/ema/wire.ts`. **Leg 3:** still local JWT bearer in `wire.ts` + `tokenEndpoint.ts` (SDK `exchangeJwtAuthGrant` lacks `resource`/`scope` — see [SDK consolidation](v2_auth_sdk_consolidation.md#sdk-adoption-wishlist)) |
|
||||
| EMA orchestration | `core/auth/ema/emaFlow.ts` — `mintEmaResourceTokens`, silent connect, connect/callback completion |
|
||||
| EMA 401 / transport | `core/auth/ema/transportProvider.ts` — `EmaTransportOAuthProvider` wraps v1 `BaseOAuthClientProvider`; re-runs legs 2–3 on expiry, IdP redirect on missing ID Token |
|
||||
| JWT expiry helpers | `core/auth/ema/jwt.ts` — `jwtExpiresAtMs`, `isJwtExpired` (exp claim only; no signature verification) |
|
||||
|
|
@ -60,28 +61,25 @@ Inspector depends on **`@modelcontextprotocol/sdk` v1.x** only (`^1.29.0` in roo
|
|||
|
||||
**EMA is not in the v1 SDK** as a named feature (`CrossAppAccess`, ID-JAG helpers, etc.). Inspector implements the protocol in `core/auth/ema/` instead.
|
||||
|
||||
#### v2 `@modelcontextprotocol/client` (not used; future option)
|
||||
#### v2 `@modelcontextprotocol/client` Layer-2 EMA helpers
|
||||
|
||||
The v2 client package (`CrossAppAccessProvider`, `discoverAndRequestJwtAuthGrant`, `exchangeJwtAuthGrant`) implements the same legs 2–3 wire format internally. It is **not** imported anywhere today. Possible future paths:
|
||||
| Leg | Status |
|
||||
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Leg 2** (`discoverAndRequestJwtAuthGrant`) | **Adopted** — `exchangeIdJag` wraps the SDK helper |
|
||||
| **Leg 3** (`exchangeJwtAuthGrant`) | **Not adopted** — helper omits `resource`/`scope` on the JWT-bearer body; keep local `redeemIdJagForAccessToken` until the [SDK wishlist](v2_auth_sdk_consolidation.md#sdk-adoption-wishlist) gap is fixed |
|
||||
| **`CrossAppAccessProvider`** | **Not adopted** — in-memory tokens; no fit for remoting / durable `OAuthStorage` / IdP leg 1 |
|
||||
|
||||
| Option | When |
|
||||
| ------ | ---- |
|
||||
| **Swap legs 2–3 to v2 Layer-2 helpers** | Replace `wire.ts` calls with `discoverAndRequestJwtAuthGrant` / `exchangeJwtAuthGrant`; keep v1 transport + `EmaTransportOAuthProvider` |
|
||||
| **Full v2 transport (Path B)** | v2 `StreamableHTTPClientTransport` + `CrossAppAccessProvider` for EMA servers only — requires v2 client stack; does **not** drop into v1 transport today |
|
||||
|
||||
**Why v1 + local wire for the first slice:** single SDK surface, custom `fetchFn` threading, inspector-owned scope/audience resolution (`resourceContext.ts`), and v1 `OAuthClientProvider` adapter for 401 re-auth — without pulling in alpha v2 client bundle surface.
|
||||
|
||||
**Watch items (unchanged):**
|
||||
**Watch items:**
|
||||
|
||||
- **401 re-auth** — EMA connections re-run legs 2–3 (leg 1 only if ID Token missing/expired); do not fall back to standard resource OAuth redirect.
|
||||
- **Future v2 adoption** — when migrating to v2 wholesale, prefer SDK Layer-2 helpers or `CrossAppAccessProvider` over maintaining duplicate wire in `wire.ts`.
|
||||
- **Leg 3 SDK adoption** — when `exchangeJwtAuthGrant` grows `resource`/`scope`, swap local wire per the consolidation wishlist.
|
||||
|
||||
## Configuration
|
||||
|
||||
Config shape matches VS Code (verified against [`mcpConfiguration.ts`](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts)). **Two credential planes** — do not merge them:
|
||||
|
||||
| Plane | Scope | What it identifies |
|
||||
| ----- | ----- | ------------------ |
|
||||
| ---------------------------- | --------------------------------------------- | -------------------------------------------------------- |
|
||||
| **IdP (client / tenant)** | Global — one per inspector install or session | Inspector's OIDC client at the enterprise IdP (legs 1–2) |
|
||||
| **Resource AS (per-server)** | Per MCP server entry | OAuth client trusted by the protected resource (leg 3) |
|
||||
|
||||
|
|
@ -104,7 +102,7 @@ All three fields are the **IdP OIDC client** credentials used in leg 2 (ID-JAG m
|
|||
Until **client profiles** (see below) land, install-level client config — starting with `enterpriseManagedAuth` — is stored in **`~/.mcp-inspector/storage/client.json`** (same generic storage API as other install-level blobs):
|
||||
|
||||
| Store | API (web) | On disk | Purpose |
|
||||
| ----- | --------- | ------- | ------- |
|
||||
| ------------ | ------------------------------------- | -------------------------------------- | -------------------------------------------------------- |
|
||||
| **`client`** | `GET/POST/DELETE /api/storage/client` | `~/.mcp-inspector/storage/client.json` | **Config** — IdP credentials, later client identity/caps |
|
||||
|
||||
**Do not** put IdP credentials in the OAuth store or per-server `mcp.json`. **Do not** use environment variables for IdP config — all clients read/write the same `client.json` file.
|
||||
|
|
@ -114,7 +112,7 @@ Until **client profiles** (see below) land, install-level client config — star
|
|||
Web uses shared file-backed OAuth via `RemoteOAuthStorage` → `/api/storage/oauth` (same `oauth.json` as CLI/TUI). Existing **sessionStorage** OAuth blobs from before that wiring are not migrated automatically — users start fresh or re-authorize once.
|
||||
|
||||
| Client | Config (`client.json`) | Runtime auth state (`OAuthStorage`) |
|
||||
| ------ | ---------------------- | ----------------------------------- |
|
||||
| ------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Web** | `RemoteStorage` adapter → `/api/storage/client` | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` (via `getWebRemoteOAuthStorage()` in `clients/web/src/lib/remoteOAuthStorage.ts`) |
|
||||
| **CLI / TUI** | `NodeClientStorage` (file adapter) | `NodeOAuthStorage` → `oauth.json` |
|
||||
|
||||
|
|
@ -183,7 +181,9 @@ const clientConfig = await loadClientConfig(); // client.json
|
|||
new InspectorClient(serverConfig, {
|
||||
...opts,
|
||||
...(getActiveEnterpriseManagedAuthIdp(clientConfig) && {
|
||||
enterpriseManagedAuth: { idp: getActiveEnterpriseManagedAuthIdp(clientConfig)! },
|
||||
enterpriseManagedAuth: {
|
||||
idp: getActiveEnterpriseManagedAuthIdp(clientConfig)!,
|
||||
},
|
||||
}),
|
||||
...(clientConfig.enterpriseManagedAuth && {
|
||||
installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth,
|
||||
|
|
@ -249,7 +249,7 @@ Extend the existing per-server `oauth` block already used for standard OAuth ([S
|
|||
```
|
||||
|
||||
| Field | When `enterpriseManaged: false` | When `enterpriseManaged: true` |
|
||||
| ----- | ------------------------------- | ------------------------------ |
|
||||
| ------------------------- | --------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `oauth.clientId` | OAuth client id (existing behavior) | **Resource AS** client id trusted by the protected resource |
|
||||
| `oauth.clientSecret` | OAuth client secret (existing behavior) | **Resource AS** client secret (keychain-backed, same as today) |
|
||||
| `oauth.scopes` | OAuth scopes (existing behavior) | Scopes for the resource token request |
|
||||
|
|
@ -318,7 +318,7 @@ CLI/TUI do not have a dedicated Client Settings dialog. They **load** `client.js
|
|||
When the user connects to a server with `oauth.enterpriseManaged: true` but install-level IdP is not active, `OAuthManager.getEmaFlowConfig()` throws `EmaClientNotConfiguredError` (`core/auth/ema/clientConfigError.ts`) with a reason-specific message:
|
||||
|
||||
| Reason | When | User-facing guidance |
|
||||
| ------ | ---- | -------------------- |
|
||||
| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `not_configured` | No IdP block, or issuer/client id missing | Open Client Settings, enable Enterprise IdP, set issuer, client ID, and secret |
|
||||
| `disabled` | `enterpriseManagedAuth.enabled === false` with IdP credentials retained | Enterprise IdP is turned off in Client Settings — re-enable and retry |
|
||||
|
||||
|
|
@ -345,7 +345,7 @@ Leg 1 is **OIDC authorization-code login** against the enterprise IdP using cred
|
|||
**Reuse existing OAuth client infrastructure** — leg 1 is not a new auth stack. It uses the same per-client mechanisms already wired for standard MCP resource OAuth (`OAuthManager`, redirect/callback, PKCE, token exchange). Only the target changes: IdP endpoints and `client.json` credentials instead of the resource authorization server and per-server `oauth.*` fields.
|
||||
|
||||
| Client | Leg 1 mechanism (same stack as standard OAuth in that client) |
|
||||
| ------ | ------------------------------------------------------------- |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Web** | Browser redirect via `navigation`; callback at `/oauth/callback`; `InspectorClient.completeOAuthFlow(code)`; `RemoteOAuthStorage` → shared `oauth.json` |
|
||||
| **TUI / CLI** | `OAuthCallbackServer` opens local callback; system browser for authorize URL; `completeOAuthFlow(code)`; `NodeOAuthStorage` |
|
||||
|
||||
|
|
@ -374,7 +374,7 @@ When an EMA server returns **403** + `insufficient_scope`, `OAuthManager.handleA
|
|||
**Web UX after Authorize:**
|
||||
|
||||
| Outcome | User sees |
|
||||
| ------- | --------- |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `satisfied` (silent re-mint) | Blue in-progress toast → green “Organization permissions were updated” (+ “Retry your action” when step-up was triggered by a tool/prompt/resource/app) |
|
||||
| `interactive` (IdP redirect) | Pre-redirect toast → full-page IdP callback → same resume snapshot behavior as other EMA flows |
|
||||
| `failed` | Red toast with error detail; triggering panel shows failure |
|
||||
|
|
@ -429,7 +429,7 @@ Inspector touchpoints to extend:
|
|||
### Clients
|
||||
|
||||
| Client | Notes |
|
||||
| ------ | ----- |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Web | **First** — implement and test EMA here; **Client Settings** for IdP + per-server EMA checkbox (HTTP/SSE only); connect via `authenticate()` on 401 or explicit auth; leg 1 via browser redirect + `/oauth/callback` (same path as standard OAuth; disambiguate pending flow) |
|
||||
| TUI | Follow web; extend existing OAuth auth tab (`clients/tui/src/App.tsx`, `AuthTab`) for EMA legs 2–3; leg 1 via existing `OAuthCallbackServer` flow |
|
||||
| CLI | Follow web/TUI; same Node OAuth stack as TUI when interactive IdP login is required |
|
||||
|
|
@ -503,8 +503,8 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che
|
|||
|
||||
- [x] **Manual staging validation** — full EMA connect against live xaa.dev (see §Staging validation). Confirms legs 1–3 and web UX outside CI.
|
||||
- [x] **Automated integration tests** — mock IdP + mock resource AS + composable protected-resource server (see §Phase 3b test plan). Live xaa.dev is **not** required for CI.
|
||||
- [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant — same happy path via `/api/storage/oauth` (standard OAuth remote E2E exists; EMA-specific variant optional).
|
||||
- [ ] *(optional)* 401 re-auth stretch — invalidate resource token, assert legs 2–3 re-run.
|
||||
- [ ] _(optional)_ `RemoteOAuthStorage` EMA E2E variant — same happy path via `/api/storage/oauth` (standard OAuth remote E2E exists; EMA-specific variant optional).
|
||||
- [ ] _(optional)_ 401 re-auth stretch — invalidate resource token, assert legs 2–3 re-run.
|
||||
|
||||
### Phase 4 — Other clients
|
||||
|
||||
|
|
@ -529,7 +529,7 @@ Full end-to-end EMA has been exercised manually against live **xaa.dev** (June 2
|
|||
### Topology
|
||||
|
||||
| Role | Service | Inspector config |
|
||||
| ---- | ------- | ---------------- |
|
||||
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Enterprise IdP** (legs 1–2) | `https://idp.xaa.dev` | **Client Settings** — issuer, requesting-app client id/secret |
|
||||
| **Resource authorization server** (leg 3) | `https://auth.resource.xaa.dev` (xaa.dev resource AS) | Per-server **Server Settings** — resource test client id/secret, EMA enabled, scopes |
|
||||
| **Protected MCP resource** | Composable test server (`test-servers/configs/xaa-ema-http.json`) — `protected-resource` mode, local streamable-http | Server catalog entry URL must match registered resource identifier (`http://localhost:8080/` — use `localhost`, not `127.0.0.1`) |
|
||||
|
|
@ -558,7 +558,7 @@ These items came out of EMA staging and apply beyond EMA. They are **not** requi
|
|||
`OAuthStorage` is implemented by **`OAuthStorageBase`** (`core/auth/oauth-storage.ts`) backed by **`OAuthMemoryStore`** (`core/auth/store.ts`) and an explicit **`OAuthPersistBackend`** (`core/auth/oauth-persist.ts`):
|
||||
|
||||
| Backend | Class | Where |
|
||||
| ------- | ----- | ----- |
|
||||
| ----------- | --------------------- | ----------------------------------------------------------------- |
|
||||
| File | `NodeOAuthStorage` | CLI, TUI, default `~/.mcp-inspector/storage/oauth.json` |
|
||||
| Remote HTTP | `RemoteOAuthStorage` | Web → `GET/POST/DELETE /api/storage/oauth` on the Hono backend |
|
||||
| Session | `BrowserOAuthStorage` | Reference/tests only; v2 web uses shared file-backed remote store |
|
||||
|
|
@ -574,7 +574,7 @@ These items came out of EMA staging and apply beyond EMA. They are **not** requi
|
|||
**Status (July 2026):** Web is wired through `RemoteOAuthStorage` (`environmentFactory.ts`, `App.tsx`, `getWebRemoteOAuthStorage()`). CLI/TUI continue to use `NodeOAuthStorage` on the same on-disk file when using the default local backend.
|
||||
|
||||
| Client | OAuth runtime store |
|
||||
| ------ | ------------------- |
|
||||
| ------------- | ----------------------------------------------------------------------------------- |
|
||||
| **Web** | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` |
|
||||
| **CLI / TUI** | `NodeOAuthStorage` → `~/.mcp-inspector/storage/oauth.json` |
|
||||
|
||||
|
|
@ -608,7 +608,7 @@ Goal: CI tests that prove EMA legs 1–3 through `InspectorClient` / `OAuthManag
|
|||
**Files:** `clients/web/src/test/core/auth/ema/wire.test.ts`, `emaFlow.test.ts`
|
||||
|
||||
| Case | Status |
|
||||
| ---- | ------ |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `exchangeIdJag` happy path | ✅ Mock IdP token endpoint; assert RFC 8693 request shape |
|
||||
| `exchangeIdJag` IdP error | ✅ Asserts leg-2 error message |
|
||||
| `redeemIdJagForAccessToken` happy path | ✅ Mock resource AS token endpoint |
|
||||
|
|
@ -636,7 +636,7 @@ Shared helper `minimalOAuthAsMetadata()` satisfies SDK OAuth metadata schema. `c
|
|||
**File:** `clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts`
|
||||
|
||||
| Step | Status |
|
||||
| ---- | ------ |
|
||||
| ---- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | ✅ Protected-resource `TestServerHttp` + `createExternalResourceOAuthTestServerConfig` (mock AS JWKS/issuers) |
|
||||
| 2 | ✅ Mock IdP + mock resource AS on ephemeral ports |
|
||||
| 3 | ✅ `InspectorClient` with `enterpriseManaged: true`, fixture IdP + resource credentials |
|
||||
|
|
@ -673,8 +673,8 @@ Existing coverage: `xaa-ema-http.json` config load, `ExternalAccessTokenValidato
|
|||
- [x] Layer 1 tests merged
|
||||
- [x] Layer 3 happy-path integration test green in CI
|
||||
- [x] Document in test file how mock topology maps to xaa.dev staging (comment block + link to `xaa-ema-http.json`)
|
||||
- [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant
|
||||
- [ ] *(optional)* 401 re-auth stretch case
|
||||
- [ ] _(optional)_ `RemoteOAuthStorage` EMA E2E variant
|
||||
- [ ] _(optional)_ 401 re-auth stretch case
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -724,6 +724,7 @@ In `mainThreadMcp`, enterprise-managed path does these implementation-specific c
|
|||
7. Call session acquisition with `audience` and `resource` in options.
|
||||
|
||||
All of this is implemented in:
|
||||
|
||||
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadMcp.ts
|
||||
|
||||
### A.4 Provider lifecycle and host plumbing
|
||||
|
|
@ -794,6 +795,7 @@ Functions to copy conceptually:
|
|||
3. JWT claims parsing helpers used to infer `client_id` from assertion
|
||||
|
||||
Tests for body semantics:
|
||||
|
||||
- https://github.com/microsoft/vscode/blob/main/src/vs/base/test/common/oauth.test.ts
|
||||
|
||||
### A.7 Secret storage model in this implementation
|
||||
|
|
@ -877,6 +879,7 @@ If you need a frozen reference (won't drift as `main` changes), use commit-pinne
|
|||
- https://github.com/microsoft/vscode/tree/2d95154af0f50db2a41ceee431acfc6d79638aab
|
||||
|
||||
For example:
|
||||
|
||||
- https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/common/extHostXaaAuthProvider.ts
|
||||
- https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/browser/mainThreadMcp.ts
|
||||
- https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/browser/mainThreadAuthentication.ts
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue