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

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:
Bob Dickinson 2026-07-19 19:36:21 -07:00 • committed by GitHub
commit a71e6d2012
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1513 additions and 852 deletions

View file

@ -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);
});
}

View file

@ -2,7 +2,6 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"baseUrl": ".",
"paths": {
"@inspector/core/*": ["../../core/*"]
}

View file

@ -4,7 +4,6 @@
"jsx": "react-jsx",
"outDir": "./build",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@inspector/core/*": ["../../core/*"]
}

View file

@ -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/);
});
});

View file

@ -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", () => {

View file

@ -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 };
}

View file

@ -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",

View file

@ -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();
}
});
});
});

View file

@ -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();

View file

@ -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

View file

@ -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();

View file

@ -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);
});
});

View file

@ -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"],

View file

@ -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"],