Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions clients/cli/__tests__/oauth-interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down Expand Up @@ -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);
});
}

Expand Down
1 change: 0 additions & 1 deletion clients/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"baseUrl": ".",
"paths": {
"@inspector/core/*": ["../../core/*"]
}
Expand Down
1 change: 0 additions & 1 deletion clients/tui/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"jsx": "react-jsx",
"outDir": "./build",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@inspector/core/*": ["../../core/*"]
}
Expand Down
172 changes: 119 additions & 53 deletions clients/web/src/test/core/auth/ema/wire.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand All @@ -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: {
Expand All @@ -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 }),
);
Expand All @@ -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);
Expand All @@ -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}`);
Expand All @@ -128,26 +150,17 @@ describe("ema wire", () => {
},
idToken: ID_TOKEN,
audience: AS_ISSUER,
resource: "https://mcp.test/resource",
resource: MCP_RESOURCE,
scope: "mcp profile",
fetchFn,
}),
).rejects.toThrow(/did not return an ID-JAG/);
});

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({
Expand All @@ -158,10 +171,63 @@ describe("ema wire", () => {
},
idToken: ID_TOKEN,
audience: AS_ISSUER,
fetchFn,
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(
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(/did not return an ID-JAG/);
});
});

describe("redeemIdJagForAccessToken", () => {
Expand Down
35 changes: 35 additions & 0 deletions clients/web/src/test/core/auth/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
14 changes: 11 additions & 3 deletions clients/web/src/test/integration/helpers/oauth-client-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down Expand Up @@ -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 };
}
Loading
Loading