feat(web,core): enterprise-managed authorization (EMA) (#1509) (#1512)

Adds enterprise-managed authorization (EMA): browser-side IdP login + RFC 8693 token-exchange and JWT-bearer resource-token mint, per-server EMA opt-in in Server Settings, IdP config in Client Settings, and IdP clientSecret keychain storage. Includes the v2/main merge and review follow-ups (400 on malformed client config; normalizeIdpIssuer import consolidation).
This commit is contained in:
Bob Dickinson 2026-06-24 19:15:12 -07:00 • committed by GitHub
commit 5de4e911e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 7585 additions and 595 deletions

View file

@ -496,6 +496,41 @@ describe("OAuth Store (Zustand)", () => {
});
});
describe("NodeOAuthStorage idpSessions (EMA)", () => {
let storage: NodeOAuthStorage;
const testStatePath = path.join(
os.tmpdir(),
`mcp-inspector-oauth-idp-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
);
const issuer = "https://idp.example.com";
afterEach(async () => {
try {
await fs.unlink(testStatePath);
} catch {
/* ignore */
}
});
it("persists IdP session keyed by issuer", async () => {
storage = new NodeOAuthStorage(testStatePath);
await storage.saveIdpSession(issuer, {
idToken: "eyJ.id.token",
refreshToken: "rt-1",
idTokenExpiresAt: 1_700_000_000_000,
});
await flushStoreFileWrites(testStatePath);
const session = await storage.getIdpSession(issuer);
expect(session?.idToken).toBe("eyJ.id.token");
expect(session?.refreshToken).toBe("rt-1");
storage.clearIdpSession(issuer);
await flushStoreFileWrites(testStatePath);
expect(await storage.getIdpSession(issuer)).toBeUndefined();
});
});
describe("NodeOAuthStorage with custom storagePath", () => {
const testServerUrl = "http://localhost:3999";

View file

@ -3,8 +3,8 @@ import {
OAuthStateMachine,
oauthTransitions,
} from "@inspector/core/auth/state-machine.js";
import type { AuthGuidedState, OAuthStep } from "@inspector/core/auth/types.js";
import { EMPTY_GUIDED_STATE } from "@inspector/core/auth/types.js";
import type { OAuthFlowState, OAuthStep } from "@inspector/core/auth/types.js";
import { EMPTY_OAUTH_FLOW_STATE } from "@inspector/core/auth/types.js";
import type { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js";
import type {
OAuthMetadata,
@ -23,12 +23,12 @@ vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
describe("OAuthStateMachine", () => {
let mockProvider: BaseOAuthClientProvider;
let updateState: (updates: Partial<AuthGuidedState>) => void;
let state: AuthGuidedState;
let updateState: (updates: Partial<OAuthFlowState>) => void;
let state: OAuthFlowState;
beforeEach(() => {
state = { ...EMPTY_GUIDED_STATE };
updateState = vi.fn((updates: Partial<AuthGuidedState>) => {
state = { ...EMPTY_OAUTH_FLOW_STATE };
updateState = vi.fn((updates: Partial<OAuthFlowState>) => {
state = { ...state, ...updates };
});
@ -347,8 +347,8 @@ describe("OAuthStateMachine", () => {
getServerMetadata: vi.fn(() => metadata),
} as unknown as BaseOAuthClientProvider;
const tokenRequestState: AuthGuidedState = {
...EMPTY_GUIDED_STATE,
const tokenRequestState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "token_request",
oauthMetadata: metadata as OAuthMetadata,
oauthClientInfo: { client_id: "test-client" },
@ -384,8 +384,8 @@ describe("OAuthStateMachine", () => {
clientInformation: vi.fn(async () => undefined),
} as unknown as BaseOAuthClientProvider;
const tokenState: AuthGuidedState = {
...EMPTY_GUIDED_STATE,
const tokenState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "token_request",
oauthMetadata: metadata as OAuthMetadata,
authorizationCode: "code-without-client",
@ -403,7 +403,7 @@ describe("OAuthStateMachine", () => {
it("complete.canTransition always returns false (terminal state)", async () => {
const result = await oauthTransitions.complete.canTransition({
state: { ...EMPTY_GUIDED_STATE, oauthStep: "complete" },
state: { ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "complete" },
serverUrl: "http://localhost:3000",
provider: mockProvider,
updateState,
@ -412,7 +412,7 @@ describe("OAuthStateMachine", () => {
// execute is a no-op
await expect(
oauthTransitions.complete.execute({
state: { ...EMPTY_GUIDED_STATE, oauthStep: "complete" },
state: { ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "complete" },
serverUrl: "http://localhost:3000",
provider: mockProvider,
updateState,
@ -429,8 +429,8 @@ describe("OAuthStateMachine", () => {
mockProvider,
updateState,
);
const blockedState: AuthGuidedState = {
...EMPTY_GUIDED_STATE,
const blockedState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "token_request",
};
await expect(stateMachine.executeStep(blockedState)).rejects.toThrow(