Fixes for remaining issues
This commit is contained in:
parent
8f1ed7588d
commit
a066873e37
12 changed files with 415 additions and 43 deletions
|
|
@ -559,16 +559,23 @@ function App({
|
||||||
);
|
);
|
||||||
|
|
||||||
// Connect — on 401 or mid-session auth recovery, run OAuth then retry.
|
// Connect — on 401 or mid-session auth recovery, run OAuth then retry.
|
||||||
|
type TuiOAuthRunResult =
|
||||||
|
| "success"
|
||||||
|
| "already_authorized"
|
||||||
|
| "insufficient_scope"
|
||||||
|
| "skipped"
|
||||||
|
| "unsupported";
|
||||||
|
|
||||||
const runOAuthAuthentication = useCallback(
|
const runOAuthAuthentication = useCallback(
|
||||||
async (options?: {
|
async (options?: {
|
||||||
challenge?: AuthChallenge;
|
challenge?: AuthChallenge;
|
||||||
authorizationUrl?: URL;
|
authorizationUrl?: URL;
|
||||||
/** When set, run OAuth for this server (may differ from the selected server). */
|
/** When set, run OAuth for this server (may differ from the selected server). */
|
||||||
serverName?: string;
|
serverName?: string;
|
||||||
}) => {
|
}): Promise<TuiOAuthRunResult> => {
|
||||||
const serverName = options?.serverName ?? selectedServer;
|
const serverName = options?.serverName ?? selectedServer;
|
||||||
if (!serverName) {
|
if (!serverName) {
|
||||||
return;
|
return "unsupported";
|
||||||
}
|
}
|
||||||
const client = inspectorClientsRef.current[serverName];
|
const client = inspectorClientsRef.current[serverName];
|
||||||
const serverEntry = mcpServersRef.current[serverName];
|
const serverEntry = mcpServersRef.current[serverName];
|
||||||
|
|
@ -578,9 +585,11 @@ function App({
|
||||||
!serverConfig ||
|
!serverConfig ||
|
||||||
!isOAuthCapableServerConfig(serverConfig)
|
!isOAuthCapableServerConfig(serverConfig)
|
||||||
) {
|
) {
|
||||||
return;
|
return "unsupported";
|
||||||
|
}
|
||||||
|
if (oauthInProgressRef.current) {
|
||||||
|
return "skipped";
|
||||||
}
|
}
|
||||||
if (oauthInProgressRef.current) return;
|
|
||||||
oauthInProgressRef.current = true;
|
oauthInProgressRef.current = true;
|
||||||
getTuiLogger().info(
|
getTuiLogger().info(
|
||||||
{ server: serverName },
|
{ server: serverName },
|
||||||
|
|
@ -594,7 +603,7 @@ function App({
|
||||||
const redirectUrlProvider = redirectUrlProvidersRef.current[serverName];
|
const redirectUrlProvider = redirectUrlProvidersRef.current[serverName];
|
||||||
if (!redirectUrlProvider) {
|
if (!redirectUrlProvider) {
|
||||||
oauthInProgressRef.current = false;
|
oauthInProgressRef.current = false;
|
||||||
return;
|
return "unsupported";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await runRunnerInteractiveOAuth({
|
const result = await runRunnerInteractiveOAuth({
|
||||||
|
|
@ -612,11 +621,13 @@ function App({
|
||||||
if (result.kind === "insufficient_scope") {
|
if (result.kind === "insufficient_scope") {
|
||||||
setOauthStatus("error");
|
setOauthStatus("error");
|
||||||
setOauthMessage(stepUpInsufficientScopeMessage(result.challenge));
|
setOauthMessage(stepUpInsufficientScopeMessage(result.challenge));
|
||||||
return;
|
return "insufficient_scope";
|
||||||
}
|
}
|
||||||
if (result.kind === "success" || result.kind === "already_authorized") {
|
if (result.kind === "success" || result.kind === "already_authorized") {
|
||||||
setOauthRevision((n) => n + 1);
|
setOauthRevision((n) => n + 1);
|
||||||
|
return result.kind;
|
||||||
}
|
}
|
||||||
|
return "unsupported";
|
||||||
} finally {
|
} finally {
|
||||||
oauthInProgressRef.current = false;
|
oauthInProgressRef.current = false;
|
||||||
callbackServerRef.current = null;
|
callbackServerRef.current = null;
|
||||||
|
|
@ -710,13 +721,21 @@ function App({
|
||||||
}
|
}
|
||||||
setOauthStatus("authenticating");
|
setOauthStatus("authenticating");
|
||||||
try {
|
try {
|
||||||
await runOAuthAuthentication({
|
const oauthResult = await runOAuthAuthentication({
|
||||||
challenge: error.authChallenge,
|
challenge: error.authChallenge,
|
||||||
authorizationUrl: error.authorizationUrl,
|
authorizationUrl: error.authorizationUrl,
|
||||||
serverName,
|
serverName,
|
||||||
});
|
});
|
||||||
setOauthStatus("idle");
|
if (
|
||||||
setOauthMessage("Authorization updated. Retry your action.");
|
oauthResult === "success" ||
|
||||||
|
oauthResult === "already_authorized"
|
||||||
|
) {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage("Authorization updated. Retry your action.");
|
||||||
|
} else if (oauthResult === "skipped") {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage("OAuth already in progress.");
|
||||||
|
}
|
||||||
} catch (authErr) {
|
} catch (authErr) {
|
||||||
const authMsg =
|
const authMsg =
|
||||||
authErr instanceof Error ? authErr.message : String(authErr);
|
authErr instanceof Error ? authErr.message : String(authErr);
|
||||||
|
|
@ -770,8 +789,16 @@ function App({
|
||||||
setOauthStatus("authenticating");
|
setOauthStatus("authenticating");
|
||||||
setOauthMessage(null);
|
setOauthMessage(null);
|
||||||
await disconnectInspector();
|
await disconnectInspector();
|
||||||
await runOAuthAuthentication();
|
const oauthResult = await runOAuthAuthentication();
|
||||||
await finishConnect();
|
if (
|
||||||
|
oauthResult === "success" ||
|
||||||
|
oauthResult === "already_authorized"
|
||||||
|
) {
|
||||||
|
await finishConnect();
|
||||||
|
} else if (oauthResult === "skipped") {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage("OAuth already in progress.");
|
||||||
|
}
|
||||||
} catch (authErr) {
|
} catch (authErr) {
|
||||||
if (authErr instanceof AuthRecoveryRequiredError) {
|
if (authErr instanceof AuthRecoveryRequiredError) {
|
||||||
handleAuthRecoveryRequired(selectedServer, authErr);
|
handleAuthRecoveryRequired(selectedServer, authErr);
|
||||||
|
|
@ -1299,8 +1326,15 @@ function App({
|
||||||
);
|
);
|
||||||
if (tabAccelerators[input.toLowerCase()]) {
|
if (tabAccelerators[input.toLowerCase()]) {
|
||||||
const nextTab = tabAccelerators[input.toLowerCase()]!;
|
const nextTab = tabAccelerators[input.toLowerCase()]!;
|
||||||
setActiveTab(nextTab);
|
const authStepUpAccelerator =
|
||||||
setFocus(nextTab === "auth" ? "tabContentList" : "tabs");
|
input.toLowerCase() === "a" &&
|
||||||
|
nextTab === "auth" &&
|
||||||
|
activeTab === "auth" &&
|
||||||
|
pendingStepUp?.serverName === selectedServer;
|
||||||
|
if (!authStepUpAccelerator) {
|
||||||
|
setActiveTab(nextTab);
|
||||||
|
setFocus(nextTab === "auth" ? "tabContentList" : "tabs");
|
||||||
|
}
|
||||||
} else if (key.tab && !key.shift) {
|
} else if (key.tab && !key.shift) {
|
||||||
// Flat focus order: servers -> tabs -> list -> details -> wrap to servers
|
// Flat focus order: servers -> tabs -> list -> details -> wrap to servers
|
||||||
const focusOrder: FocusArea[] =
|
const focusOrder: FocusArea[] =
|
||||||
|
|
@ -1689,14 +1723,22 @@ function App({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (outcome.kind === "interactive") {
|
if (outcome.kind === "interactive") {
|
||||||
await runOAuthAuthentication({
|
const oauthResult = await runOAuthAuthentication({
|
||||||
challenge: outcome.challenge,
|
challenge: outcome.challenge,
|
||||||
authorizationUrl: outcome.authorizationUrl,
|
authorizationUrl: outcome.authorizationUrl,
|
||||||
});
|
});
|
||||||
setOauthStatus("idle");
|
if (
|
||||||
setOauthMessage(
|
oauthResult === "success" ||
|
||||||
"Step-up authorization succeeded. Retry your action.",
|
oauthResult === "already_authorized"
|
||||||
);
|
) {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage(
|
||||||
|
"Step-up authorization succeeded. Retry your action.",
|
||||||
|
);
|
||||||
|
} else if (oauthResult === "skipped") {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage("OAuth already in progress.");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (outcome.kind === "failed") {
|
if (outcome.kind === "failed") {
|
||||||
|
|
@ -1712,14 +1754,22 @@ function App({
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runOAuthAuthentication({
|
const oauthResult = await runOAuthAuthentication({
|
||||||
challenge,
|
challenge,
|
||||||
authorizationUrl,
|
authorizationUrl,
|
||||||
});
|
});
|
||||||
setOauthStatus("idle");
|
if (
|
||||||
setOauthMessage(
|
oauthResult === "success" ||
|
||||||
"Step-up authorization succeeded. Retry your action.",
|
oauthResult === "already_authorized"
|
||||||
);
|
) {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage(
|
||||||
|
"Step-up authorization succeeded. Retry your action.",
|
||||||
|
);
|
||||||
|
} else if (oauthResult === "skipped") {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage("OAuth already in progress.");
|
||||||
|
}
|
||||||
} catch (authErr) {
|
} catch (authErr) {
|
||||||
const authMsg =
|
const authMsg =
|
||||||
authErr instanceof Error
|
authErr instanceof Error
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
computeScopeUnion,
|
computeScopeUnion,
|
||||||
isStrictScopeSuperset,
|
isStrictScopeSuperset,
|
||||||
|
resolveEffectiveGrantedScope,
|
||||||
|
resolvePersistedScopeAfterGrant,
|
||||||
} from "@inspector/core/auth/scopes.js";
|
} from "@inspector/core/auth/scopes.js";
|
||||||
|
|
||||||
describe("scopes", () => {
|
describe("scopes", () => {
|
||||||
|
|
@ -34,4 +36,32 @@ describe("scopes", () => {
|
||||||
expect(isStrictScopeSuperset("mcp weather:read", undefined)).toBe(true);
|
expect(isStrictScopeSuperset("mcp weather:read", undefined)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolvePersistedScopeAfterGrant", () => {
|
||||||
|
it("prefers explicit granted scope over requested", () => {
|
||||||
|
expect(resolvePersistedScopeAfterGrant("mcp", "mcp weather:read")).toBe(
|
||||||
|
"mcp",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to requested scope when grant omits scope", () => {
|
||||||
|
expect(
|
||||||
|
resolvePersistedScopeAfterGrant(undefined, "mcp weather:read"),
|
||||||
|
).toBe("mcp weather:read");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveEffectiveGrantedScope", () => {
|
||||||
|
it("uses token scope when present even if storage overstates grant", () => {
|
||||||
|
expect(resolveEffectiveGrantedScope("mcp weather:read", "mcp")).toBe(
|
||||||
|
"mcp",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to stored scope when token omits scope", () => {
|
||||||
|
expect(resolveEffectiveGrantedScope("mcp tools:read", undefined)).toBe(
|
||||||
|
"mcp tools:read",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { AuthChallengeError } from "@inspector/core/auth/challenge.js";
|
||||||
|
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||||
|
|
||||||
|
describe("InspectorClient direct auth recovery retry bound", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries an operation once after satisfied recovery, then gives up", async () => {
|
||||||
|
const client = Object.create(InspectorClient.prototype) as InspectorClient;
|
||||||
|
const internals = client as unknown as {
|
||||||
|
directAuthRecovery: boolean;
|
||||||
|
directAuthRecoveryActive: boolean;
|
||||||
|
reconnectAfterAuthRecovery: ReturnType<typeof vi.fn>;
|
||||||
|
withDirectAuthRecovery: <T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
context?: { method?: string; toolName?: string },
|
||||||
|
attempt?: number,
|
||||||
|
) => Promise<T>;
|
||||||
|
};
|
||||||
|
internals.directAuthRecovery = true;
|
||||||
|
internals.directAuthRecoveryActive = true;
|
||||||
|
internals.reconnectAfterAuthRecovery = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const challenge = {
|
||||||
|
reason: "insufficient_scope" as const,
|
||||||
|
requiredScopes: ["weather:read"],
|
||||||
|
};
|
||||||
|
const operation = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new AuthChallengeError(challenge, 403));
|
||||||
|
|
||||||
|
vi.spyOn(client, "handleAuthChallenge").mockResolvedValue({
|
||||||
|
kind: "satisfied",
|
||||||
|
});
|
||||||
|
vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
internals.withDirectAuthRecovery.call(client, operation, {
|
||||||
|
method: "tools/call",
|
||||||
|
toolName: "get_temp",
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(AuthChallengeError);
|
||||||
|
|
||||||
|
expect(operation).toHaveBeenCalledTimes(2);
|
||||||
|
expect(client.handleAuthChallenge).toHaveBeenCalledTimes(1);
|
||||||
|
expect(internals.reconnectAfterAuthRecovery).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -606,6 +606,54 @@ describe("OAuthManager", () => {
|
||||||
);
|
);
|
||||||
captureSpy.mockRestore();
|
captureSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists granted scope when AS down-scopes the token response", async () => {
|
||||||
|
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
|
||||||
|
const params = createMockParams();
|
||||||
|
storageOf(params).getScope.mockReturnValue("mcp");
|
||||||
|
storageOf(params).getTokens.mockResolvedValue({
|
||||||
|
access_token: "access",
|
||||||
|
token_type: "Bearer",
|
||||||
|
scope: "mcp",
|
||||||
|
});
|
||||||
|
storageOf(params).getClientInformation.mockResolvedValue({
|
||||||
|
client_id: "cid",
|
||||||
|
});
|
||||||
|
const manager = new OAuthManager(params);
|
||||||
|
(
|
||||||
|
manager as unknown as { pendingAuthorizationScope: string | undefined }
|
||||||
|
).pendingAuthorizationScope = "mcp weather:read";
|
||||||
|
|
||||||
|
await manager.completeOAuthFlow("code");
|
||||||
|
|
||||||
|
expect(storageOf(params).saveScope).toHaveBeenCalledWith(
|
||||||
|
SERVER_URL,
|
||||||
|
"mcp",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists requested scope when the token response omits scope", async () => {
|
||||||
|
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
|
||||||
|
const params = createMockParams();
|
||||||
|
storageOf(params).getTokens.mockResolvedValue({
|
||||||
|
access_token: "access",
|
||||||
|
token_type: "Bearer",
|
||||||
|
});
|
||||||
|
storageOf(params).getClientInformation.mockResolvedValue({
|
||||||
|
client_id: "cid",
|
||||||
|
});
|
||||||
|
const manager = new OAuthManager(params);
|
||||||
|
(
|
||||||
|
manager as unknown as { pendingAuthorizationScope: string | undefined }
|
||||||
|
).pendingAuthorizationScope = "mcp weather:read";
|
||||||
|
|
||||||
|
await manager.completeOAuthFlow("code");
|
||||||
|
|
||||||
|
expect(storageOf(params).saveScope).toHaveBeenCalledWith(
|
||||||
|
SERVER_URL,
|
||||||
|
"mcp weather:read",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("completeOAuthFlow (EMA)", () => {
|
describe("completeOAuthFlow (EMA)", () => {
|
||||||
|
|
@ -823,6 +871,34 @@ describe("OAuthManager", () => {
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns false for invalid_token even when a locally valid token exists", async () => {
|
||||||
|
const params = createMockParams();
|
||||||
|
storageOf(params).getTokens.mockResolvedValue({
|
||||||
|
access_token: "tok",
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: 3600,
|
||||||
|
});
|
||||||
|
const manager = new OAuthManager(params);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await manager.checkAuthChallengeSatisfied({ reason: "invalid_token" }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for unauthorized even when a locally valid token exists", async () => {
|
||||||
|
const params = createMockParams();
|
||||||
|
storageOf(params).getTokens.mockResolvedValue({
|
||||||
|
access_token: "tok",
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: 3600,
|
||||||
|
});
|
||||||
|
const manager = new OAuthManager(params);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await manager.checkAuthChallengeSatisfied({ reason: "unauthorized" }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns true when stored scope covers step-up union", async () => {
|
it("returns true when stored scope covers step-up union", async () => {
|
||||||
const params = createMockParams();
|
const params = createMockParams();
|
||||||
storageOf(params).getTokens.mockResolvedValue({
|
storageOf(params).getTokens.mockResolvedValue({
|
||||||
|
|
@ -859,6 +935,24 @@ describe("OAuthManager", () => {
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores inflated stored scope when token scope is explicit", async () => {
|
||||||
|
const params = createMockParams();
|
||||||
|
storageOf(params).getTokens.mockResolvedValue({
|
||||||
|
access_token: "tok",
|
||||||
|
token_type: "Bearer",
|
||||||
|
scope: "mcp",
|
||||||
|
});
|
||||||
|
storageOf(params).getScope.mockReturnValue("mcp weather:read");
|
||||||
|
const manager = new OAuthManager(params);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await manager.checkAuthChallengeSatisfied({
|
||||||
|
reason: "insufficient_scope",
|
||||||
|
requiredScopes: ["weather:read"],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns false for insufficient_scope with no scopes in the challenge", async () => {
|
it("returns false for insufficient_scope with no scopes in the challenge", async () => {
|
||||||
const params = createMockParams();
|
const params = createMockParams();
|
||||||
storageOf(params).getTokens.mockResolvedValue({
|
storageOf(params).getTokens.mockResolvedValue({
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,50 @@ describe("test server scope requirements", () => {
|
||||||
expect(result.status).toBe(200);
|
expect(result.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("enforces requiredScopes on resource templates for resources/read", () => {
|
||||||
|
const registry = buildScopeRequirementRegistry({
|
||||||
|
...createMinimalConfig({ tools: [] }),
|
||||||
|
resourceTemplates: [
|
||||||
|
{
|
||||||
|
name: "files",
|
||||||
|
uriTemplate: "file:///{path}",
|
||||||
|
requiredScopes: ["files:read"],
|
||||||
|
handler: async () => ({
|
||||||
|
contents: [{ uri: "file:///tmp/x", text: "ok" }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(registry.resourceTemplates.get("file:///{path}")).toEqual([
|
||||||
|
"files:read",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const denied = invokeScopeMiddleware(
|
||||||
|
registry,
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
method: "resources/read",
|
||||||
|
params: { uri: "file:///tmp/example.txt" },
|
||||||
|
},
|
||||||
|
mintTestAccessToken("mcp"),
|
||||||
|
);
|
||||||
|
expect(denied.next).toBe(false);
|
||||||
|
expect(denied.status).toBe(403);
|
||||||
|
|
||||||
|
const allowed = invokeScopeMiddleware(
|
||||||
|
registry,
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 3,
|
||||||
|
method: "resources/read",
|
||||||
|
params: { uri: "file:///tmp/example.txt" },
|
||||||
|
},
|
||||||
|
mintTestAccessToken("mcp files:read"),
|
||||||
|
);
|
||||||
|
expect(allowed.next).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses oauthTokenScopes attached by bearer middleware (external JWT path)", () => {
|
it("uses oauthTokenScopes attached by bearer middleware (external JWT path)", () => {
|
||||||
const registry = buildScopeRequirementRegistry({
|
const registry = buildScopeRequirementRegistry({
|
||||||
...createMinimalConfig(),
|
...createMinimalConfig(),
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,18 @@ describe("RemoteSession", () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("cancelRequestWait clears the timeout for a pending wait", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const session = new RemoteSession("s-cancel");
|
||||||
|
const wait = session.waitForRequestResponse(42, 5000);
|
||||||
|
const rejection = expect(wait).rejects.toThrow(/cancelled/);
|
||||||
|
session.cancelRequestWait(42);
|
||||||
|
await rejection;
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("setAuthState updates the session auth provider", async () => {
|
it("setAuthState updates the session auth provider", async () => {
|
||||||
const { createRemoteAuthProvider } =
|
const { createRemoteAuthProvider } =
|
||||||
await import("@inspector/core/mcp/remote/node/tokenAuthProvider.js");
|
await import("@inspector/core/mcp/remote/node/tokenAuthProvider.js");
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,35 @@ export function isStrictScopeSuperset(
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to persist after a successful token grant (RFC 6749 §5.1).
|
||||||
|
* When the AS returns `scope`, it is the authoritative full grant.
|
||||||
|
* When `scope` is omitted on success, granted equals what was requested.
|
||||||
|
*/
|
||||||
|
export function resolvePersistedScopeAfterGrant(
|
||||||
|
grantedScope: string | undefined,
|
||||||
|
requestedScope: string | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const granted = grantedScope?.trim();
|
||||||
|
if (granted) {
|
||||||
|
return granted;
|
||||||
|
}
|
||||||
|
const requested = requestedScope?.trim();
|
||||||
|
return requested || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope coverage for satisfaction checks: prefer the token's explicit grant;
|
||||||
|
* when omitted, fall back to stored scope (RFC implied grant on prior success).
|
||||||
|
*/
|
||||||
|
export function resolveEffectiveGrantedScope(
|
||||||
|
storedScope: string | undefined,
|
||||||
|
tokenScope: string | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const granted = tokenScope?.trim();
|
||||||
|
if (granted) {
|
||||||
|
return granted;
|
||||||
|
}
|
||||||
|
return computeScopeUnion(storedScope, tokenScope);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ import {
|
||||||
import {
|
import {
|
||||||
computeScopeUnion,
|
computeScopeUnion,
|
||||||
isStrictScopeSuperset,
|
isStrictScopeSuperset,
|
||||||
|
resolveEffectiveGrantedScope,
|
||||||
|
resolvePersistedScopeAfterGrant,
|
||||||
} from "../auth/scopes.js";
|
} from "../auth/scopes.js";
|
||||||
import { stepUpInsufficientScopeMessage } from "../auth/oauthUx.js";
|
import { stepUpInsufficientScopeMessage } from "../auth/oauthUx.js";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -276,10 +278,15 @@ export class OAuthManager {
|
||||||
config,
|
config,
|
||||||
authorizationCode,
|
authorizationCode,
|
||||||
);
|
);
|
||||||
if (this.pendingAuthorizationScope) {
|
const requestedScope = this.pendingAuthorizationScope;
|
||||||
|
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||||||
|
tokens.scope,
|
||||||
|
requestedScope,
|
||||||
|
);
|
||||||
|
if (scopeToPersist) {
|
||||||
await this.oauthConfig.storage!.saveScope(
|
await this.oauthConfig.storage!.saveScope(
|
||||||
this.getServerUrl(),
|
this.getServerUrl(),
|
||||||
this.pendingAuthorizationScope,
|
scopeToPersist,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.pendingAuthorizationScope = undefined;
|
this.pendingAuthorizationScope = undefined;
|
||||||
|
|
@ -315,8 +322,11 @@ export class OAuthManager {
|
||||||
throw new Error("Failed to retrieve tokens after authorization");
|
throw new Error("Failed to retrieve tokens after authorization");
|
||||||
}
|
}
|
||||||
|
|
||||||
const scopeToPersist =
|
const requestedScope = this.pendingAuthorizationScope;
|
||||||
this.pendingAuthorizationScope ?? tokens.scope;
|
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||||||
|
tokens.scope,
|
||||||
|
requestedScope,
|
||||||
|
);
|
||||||
if (scopeToPersist) {
|
if (scopeToPersist) {
|
||||||
await provider.saveScope(scopeToPersist);
|
await provider.saveScope(scopeToPersist);
|
||||||
}
|
}
|
||||||
|
|
@ -431,8 +441,9 @@ export class OAuthManager {
|
||||||
* satisfied without an authorization-server round-trip.
|
* satisfied without an authorization-server round-trip.
|
||||||
*
|
*
|
||||||
* Returns `true` for `insufficient_scope` when stored + token scope cover the
|
* Returns `true` for `insufficient_scope` when stored + token scope cover the
|
||||||
* SEP-2350 union. For `token_expired` / `unauthorized` / `invalid_token`,
|
* SEP-2350 union. For `token_expired`, returns `true` when a usable access
|
||||||
* returns `true` when a usable access token is already in storage.
|
* token is already in storage. `invalid_token` and `unauthorized` always
|
||||||
|
* return `false` — the resource server explicitly rejected the credential.
|
||||||
*/
|
*/
|
||||||
async checkAuthChallengeSatisfied(
|
async checkAuthChallengeSatisfied(
|
||||||
challenge: AuthChallenge,
|
challenge: AuthChallenge,
|
||||||
|
|
@ -450,10 +461,8 @@ export class OAuthManager {
|
||||||
|
|
||||||
if (challenge.reason !== "insufficient_scope") {
|
if (challenge.reason !== "insufficient_scope") {
|
||||||
return (
|
return (
|
||||||
challenge.reason === "token_expired" ||
|
challenge.reason === "token_expired" && isAccessTokenUsable(tokens)
|
||||||
challenge.reason === "unauthorized" ||
|
);
|
||||||
challenge.reason === "invalid_token"
|
|
||||||
) && isAccessTokenUsable(tokens);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const enriched = await this.enrichChallengeWithAuthorizationScopes(
|
const enriched = await this.enrichChallengeWithAuthorizationScopes(
|
||||||
|
|
@ -467,7 +476,7 @@ export class OAuthManager {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveScope = computeScopeUnion(
|
const effectiveScope = resolveEffectiveGrantedScope(
|
||||||
storage.getScope(serverUrl),
|
storage.getScope(serverUrl),
|
||||||
tokens.scope,
|
tokens.scope,
|
||||||
);
|
);
|
||||||
|
|
@ -603,10 +612,17 @@ export class OAuthManager {
|
||||||
const silent = await trySilentEmaAuth(config);
|
const silent = await trySilentEmaAuth(config);
|
||||||
if (silent.status === "success") {
|
if (silent.status === "success") {
|
||||||
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
||||||
if (enriched.authorizationScopes?.length) {
|
const minted = await this.oauthConfig.storage!.getTokens(
|
||||||
|
this.getServerUrl(),
|
||||||
|
);
|
||||||
|
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||||||
|
minted?.scope,
|
||||||
|
enriched.authorizationScopes?.join(" "),
|
||||||
|
);
|
||||||
|
if (scopeToPersist) {
|
||||||
await this.oauthConfig.storage!.saveScope(
|
await this.oauthConfig.storage!.saveScope(
|
||||||
this.getServerUrl(),
|
this.getServerUrl(),
|
||||||
enriched.authorizationScopes.join(" "),
|
scopeToPersist,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { kind: "satisfied" };
|
return { kind: "satisfied" };
|
||||||
|
|
@ -688,8 +704,13 @@ export class OAuthManager {
|
||||||
if (result === "AUTHORIZED") {
|
if (result === "AUTHORIZED") {
|
||||||
if (enriched.reason === "insufficient_scope") {
|
if (enriched.reason === "insufficient_scope") {
|
||||||
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
||||||
if (scopeForAuth) {
|
const freshTokens = await provider.tokens();
|
||||||
await provider.saveScope(scopeForAuth);
|
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||||||
|
freshTokens?.scope,
|
||||||
|
scopeForAuth,
|
||||||
|
);
|
||||||
|
if (scopeToPersist) {
|
||||||
|
await provider.saveScope(scopeToPersist);
|
||||||
}
|
}
|
||||||
return { kind: "satisfied" };
|
return { kind: "satisfied" };
|
||||||
}
|
}
|
||||||
|
|
@ -799,8 +820,13 @@ export class OAuthManager {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
||||||
if (scopeForAuth) {
|
const freshTokens = await provider.tokens();
|
||||||
await provider.saveScope(scopeForAuth);
|
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||||||
|
freshTokens?.scope,
|
||||||
|
scopeForAuth,
|
||||||
|
);
|
||||||
|
if (scopeToPersist) {
|
||||||
|
await provider.saveScope(scopeToPersist);
|
||||||
}
|
}
|
||||||
return { kind: "satisfied" };
|
return { kind: "satisfied" };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,12 @@ export class RemoteSession {
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelRequestWait(requestId: string | number): void {
|
cancelRequestWait(requestId: string | number): void {
|
||||||
|
const wait = this.requestWaits.get(requestId);
|
||||||
|
if (!wait) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.requestWaits.delete(requestId);
|
this.requestWaits.delete(requestId);
|
||||||
|
wait.reject(new Error("MCP request wait cancelled"));
|
||||||
}
|
}
|
||||||
|
|
||||||
rejectActiveRequestWaits(error: Error): void {
|
rejectActiveRequestWaits(error: Error): void {
|
||||||
|
|
|
||||||
|
|
@ -578,7 +578,12 @@ export class RemoteClientTransport implements Transport {
|
||||||
}
|
}
|
||||||
|
|
||||||
private cancelSseResponseWait(requestId: string | number): void {
|
private cancelSseResponseWait(requestId: string | number): void {
|
||||||
|
const wait = this.sseResponseWaits.get(requestId);
|
||||||
|
if (!wait) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.sseResponseWaits.delete(requestId);
|
this.sseResponseWaits.delete(requestId);
|
||||||
|
wait.reject(new Error("SSE response wait cancelled"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private cancelAllSseWaits(error: Error): void {
|
private cancelAllSseWaits(error: Error): void {
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,9 @@ By contrast, **401** means the token is missing, invalid, or expired — fix by
|
||||||
| **403** step-up | **Union** previously requested scopes with scopes from the challenge — do not drop scopes needed for other tools |
|
| **403** step-up | **Union** previously requested scopes with scopes from the challenge — do not drop scopes needed for other tools |
|
||||||
| **401** re-login | **Replace** scope set (user may down-scope at the AS) |
|
| **401** re-login | **Replace** scope set (user may down-scope at the AS) |
|
||||||
|
|
||||||
Inspector persists the previously requested set in `OAuthStorage.scope` (`saveScope()`), computes `authorizationScopes` as the union in `handleAuthChallenge()`, and only persists the union after a **successful** `completeOAuthFlow()`.
|
Inspector persists granted scopes in `OAuthStorage.scope` (`saveScope()`): when the authorization server returns a `scope` parameter on the token response, that value is **authoritative** (RFC 6749 §5.1 — including when the grant is a subset of what was requested). When `scope` is **omitted** on a successful response, granted scope is assumed to equal what was requested for that exchange. Storage must never claim scopes the access token was not granted.
|
||||||
|
|
||||||
|
For step-up, `handleAuthChallenge()` computes `authorizationScopes` as the union of stored grant + challenge scopes for the **authorization request**; `saveScope()` runs only after a successful grant, using the rules above — not the pre-redirect requested union when the AS returned an explicit smaller `scope`.
|
||||||
|
|
||||||
**UX consequence:** standard-OAuth and **web EMA** step-up need **user-visible consent** before proceeding (web modal, TUI Auth tab confirm, CLI **y/N**). On the web client, EMA `insufficient_scope` shows the same **`StepUpAuthModal`** pattern as standard OAuth, with organization/IdP copy; only after **Authorize** does Inspector run silent re-mint or start an IdP redirect. TUI/CLI may still re-mint silently after their own confirm prompt — see [EMA step-up (web)](v2_auth_ema.md#ema-step-up-web-confirmation).
|
**UX consequence:** standard-OAuth and **web EMA** step-up need **user-visible consent** before proceeding (web modal, TUI Auth tab confirm, CLI **y/N**). On the web client, EMA `insufficient_scope` shows the same **`StepUpAuthModal`** pattern as standard OAuth, with organization/IdP copy; only after **Authorize** does Inspector run silent re-mint or start an IdP redirect. TUI/CLI may still re-mint silently after their own confirm prompt — see [EMA step-up (web)](v2_auth_ema.md#ema-step-up-web-confirmation).
|
||||||
|
|
||||||
|
|
@ -214,7 +216,7 @@ Read-only check against **current storage** (and token expiry helpers). Used bef
|
||||||
| `token_expired`, `invalid_token`, `unauthorized` | Refresh via `refresh_token` when supported | Authorization code flow (`authenticate()`) |
|
| `token_expired`, `invalid_token`, `unauthorized` | Refresh via `refresh_token` when supported | Authorization code flow (`authenticate()`) |
|
||||||
| `insufficient_scope` | N/A | Authorize with **`authorizationScopes`** = union(previous, challenge) via `mcpAuth({ forceReauthorization: true })`; navigation **deferred** until UI confirms (web modal / TUI Auth / CLI prompt) |
|
| `insufficient_scope` | N/A | Authorize with **`authorizationScopes`** = union(previous, challenge) via `mcpAuth({ forceReauthorization: true })`; navigation **deferred** until UI confirms (web modal / TUI Auth / CLI prompt) |
|
||||||
|
|
||||||
Union scope is held in `pendingAuthorizationScope` until `completeOAuthFlow()` succeeds; cleared on failure.
|
Union scope is held in `pendingAuthorizationScope` until `completeOAuthFlow()` succeeds; cleared on failure. On success, `saveScope()` stores the AS-granted scope (explicit `scope` on the token response, or the requested union when `scope` is omitted per RFC 6749 §5.1).
|
||||||
|
|
||||||
#### EMA
|
#### EMA
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -837,6 +837,13 @@ export interface ScopeRequirementRegistry {
|
||||||
tools: Map<string, string[]>;
|
tools: Map<string, string[]>;
|
||||||
resources: Map<string, string[]>;
|
resources: Map<string, string[]>;
|
||||||
prompts: Map<string, string[]>;
|
prompts: Map<string, string[]>;
|
||||||
|
resourceTemplates: Map<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceUriMatchesTemplate(uri: string, uriTemplate: string): boolean {
|
||||||
|
const brace = uriTemplate.indexOf("{");
|
||||||
|
const prefix = brace >= 0 ? uriTemplate.slice(0, brace) : uriTemplate;
|
||||||
|
return uri.startsWith(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build lookup tables from merged ServerConfig capability definitions. */
|
/** Build lookup tables from merged ServerConfig capability definitions. */
|
||||||
|
|
@ -847,6 +854,7 @@ export function buildScopeRequirementRegistry(
|
||||||
tools: new Map(),
|
tools: new Map(),
|
||||||
resources: new Map(),
|
resources: new Map(),
|
||||||
prompts: new Map(),
|
prompts: new Map(),
|
||||||
|
resourceTemplates: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const tool of config.tools ?? []) {
|
for (const tool of config.tools ?? []) {
|
||||||
|
|
@ -864,6 +872,11 @@ export function buildScopeRequirementRegistry(
|
||||||
registry.prompts.set(prompt.name, prompt.requiredScopes);
|
registry.prompts.set(prompt.name, prompt.requiredScopes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const template of config.resourceTemplates ?? []) {
|
||||||
|
if (template.requiredScopes?.length) {
|
||||||
|
registry.resourceTemplates.set(template.uriTemplate, template.requiredScopes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|
@ -874,7 +887,8 @@ export function scopeRequirementRegistryHasEntries(
|
||||||
return (
|
return (
|
||||||
registry.tools.size > 0 ||
|
registry.tools.size > 0 ||
|
||||||
registry.resources.size > 0 ||
|
registry.resources.size > 0 ||
|
||||||
registry.prompts.size > 0
|
registry.prompts.size > 0 ||
|
||||||
|
registry.resourceTemplates.size > 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -937,6 +951,14 @@ export function createScopeCheckMiddleware(
|
||||||
requiredScopes = registry.tools.get(target);
|
requiredScopes = registry.tools.get(target);
|
||||||
} else if (method === "resources/read") {
|
} else if (method === "resources/read") {
|
||||||
requiredScopes = registry.resources.get(target);
|
requiredScopes = registry.resources.get(target);
|
||||||
|
if (!requiredScopes?.length) {
|
||||||
|
for (const [uriTemplate, scopes] of registry.resourceTemplates) {
|
||||||
|
if (resourceUriMatchesTemplate(target, uriTemplate)) {
|
||||||
|
requiredScopes = scopes;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (method === "prompts/get") {
|
} else if (method === "prompts/get") {
|
||||||
requiredScopes = registry.prompts.get(target);
|
requiredScopes = registry.prompts.get(target);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue