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
6 changed files with 1513 additions and 852 deletions
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue