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.
|
||||
type TuiOAuthRunResult =
|
||||
| "success"
|
||||
| "already_authorized"
|
||||
| "insufficient_scope"
|
||||
| "skipped"
|
||||
| "unsupported";
|
||||
|
||||
const runOAuthAuthentication = useCallback(
|
||||
async (options?: {
|
||||
challenge?: AuthChallenge;
|
||||
authorizationUrl?: URL;
|
||||
/** When set, run OAuth for this server (may differ from the selected server). */
|
||||
serverName?: string;
|
||||
}) => {
|
||||
}): Promise<TuiOAuthRunResult> => {
|
||||
const serverName = options?.serverName ?? selectedServer;
|
||||
if (!serverName) {
|
||||
return;
|
||||
return "unsupported";
|
||||
}
|
||||
const client = inspectorClientsRef.current[serverName];
|
||||
const serverEntry = mcpServersRef.current[serverName];
|
||||
|
|
@ -578,9 +585,11 @@ function App({
|
|||
!serverConfig ||
|
||||
!isOAuthCapableServerConfig(serverConfig)
|
||||
) {
|
||||
return;
|
||||
return "unsupported";
|
||||
}
|
||||
if (oauthInProgressRef.current) {
|
||||
return "skipped";
|
||||
}
|
||||
if (oauthInProgressRef.current) return;
|
||||
oauthInProgressRef.current = true;
|
||||
getTuiLogger().info(
|
||||
{ server: serverName },
|
||||
|
|
@ -594,7 +603,7 @@ function App({
|
|||
const redirectUrlProvider = redirectUrlProvidersRef.current[serverName];
|
||||
if (!redirectUrlProvider) {
|
||||
oauthInProgressRef.current = false;
|
||||
return;
|
||||
return "unsupported";
|
||||
}
|
||||
try {
|
||||
const result = await runRunnerInteractiveOAuth({
|
||||
|
|
@ -612,11 +621,13 @@ function App({
|
|||
if (result.kind === "insufficient_scope") {
|
||||
setOauthStatus("error");
|
||||
setOauthMessage(stepUpInsufficientScopeMessage(result.challenge));
|
||||
return;
|
||||
return "insufficient_scope";
|
||||
}
|
||||
if (result.kind === "success" || result.kind === "already_authorized") {
|
||||
setOauthRevision((n) => n + 1);
|
||||
return result.kind;
|
||||
}
|
||||
return "unsupported";
|
||||
} finally {
|
||||
oauthInProgressRef.current = false;
|
||||
callbackServerRef.current = null;
|
||||
|
|
@ -710,13 +721,21 @@ function App({
|
|||
}
|
||||
setOauthStatus("authenticating");
|
||||
try {
|
||||
await runOAuthAuthentication({
|
||||
const oauthResult = await runOAuthAuthentication({
|
||||
challenge: error.authChallenge,
|
||||
authorizationUrl: error.authorizationUrl,
|
||||
serverName,
|
||||
});
|
||||
setOauthStatus("idle");
|
||||
setOauthMessage("Authorization updated. Retry your action.");
|
||||
if (
|
||||
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) {
|
||||
const authMsg =
|
||||
authErr instanceof Error ? authErr.message : String(authErr);
|
||||
|
|
@ -770,8 +789,16 @@ function App({
|
|||
setOauthStatus("authenticating");
|
||||
setOauthMessage(null);
|
||||
await disconnectInspector();
|
||||
await runOAuthAuthentication();
|
||||
await finishConnect();
|
||||
const oauthResult = await runOAuthAuthentication();
|
||||
if (
|
||||
oauthResult === "success" ||
|
||||
oauthResult === "already_authorized"
|
||||
) {
|
||||
await finishConnect();
|
||||
} else if (oauthResult === "skipped") {
|
||||
setOauthStatus("idle");
|
||||
setOauthMessage("OAuth already in progress.");
|
||||
}
|
||||
} catch (authErr) {
|
||||
if (authErr instanceof AuthRecoveryRequiredError) {
|
||||
handleAuthRecoveryRequired(selectedServer, authErr);
|
||||
|
|
@ -1299,8 +1326,15 @@ function App({
|
|||
);
|
||||
if (tabAccelerators[input.toLowerCase()]) {
|
||||
const nextTab = tabAccelerators[input.toLowerCase()]!;
|
||||
setActiveTab(nextTab);
|
||||
setFocus(nextTab === "auth" ? "tabContentList" : "tabs");
|
||||
const authStepUpAccelerator =
|
||||
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) {
|
||||
// Flat focus order: servers -> tabs -> list -> details -> wrap to servers
|
||||
const focusOrder: FocusArea[] =
|
||||
|
|
@ -1689,14 +1723,22 @@ function App({
|
|||
return;
|
||||
}
|
||||
if (outcome.kind === "interactive") {
|
||||
await runOAuthAuthentication({
|
||||
const oauthResult = await runOAuthAuthentication({
|
||||
challenge: outcome.challenge,
|
||||
authorizationUrl: outcome.authorizationUrl,
|
||||
});
|
||||
setOauthStatus("idle");
|
||||
setOauthMessage(
|
||||
"Step-up authorization succeeded. Retry your action.",
|
||||
);
|
||||
if (
|
||||
oauthResult === "success" ||
|
||||
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;
|
||||
}
|
||||
if (outcome.kind === "failed") {
|
||||
|
|
@ -1712,14 +1754,22 @@ function App({
|
|||
);
|
||||
return;
|
||||
}
|
||||
await runOAuthAuthentication({
|
||||
const oauthResult = await runOAuthAuthentication({
|
||||
challenge,
|
||||
authorizationUrl,
|
||||
});
|
||||
setOauthStatus("idle");
|
||||
setOauthMessage(
|
||||
"Step-up authorization succeeded. Retry your action.",
|
||||
);
|
||||
if (
|
||||
oauthResult === "success" ||
|
||||
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) {
|
||||
const authMsg =
|
||||
authErr instanceof Error
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
|
|||
import {
|
||||
computeScopeUnion,
|
||||
isStrictScopeSuperset,
|
||||
resolveEffectiveGrantedScope,
|
||||
resolvePersistedScopeAfterGrant,
|
||||
} from "@inspector/core/auth/scopes.js";
|
||||
|
||||
describe("scopes", () => {
|
||||
|
|
@ -34,4 +36,32 @@ describe("scopes", () => {
|
|||
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();
|
||||
});
|
||||
|
||||
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)", () => {
|
||||
|
|
@ -823,6 +871,34 @@ describe("OAuthManager", () => {
|
|||
).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 () => {
|
||||
const params = createMockParams();
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
|
|
@ -859,6 +935,24 @@ describe("OAuthManager", () => {
|
|||
).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 () => {
|
||||
const params = createMockParams();
|
||||
storageOf(params).getTokens.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -121,6 +121,50 @@ describe("test server scope requirements", () => {
|
|||
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)", () => {
|
||||
const registry = buildScopeRequirementRegistry({
|
||||
...createMinimalConfig(),
|
||||
|
|
|
|||
|
|
@ -267,6 +267,18 @@ describe("RemoteSession", () => {
|
|||
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 () => {
|
||||
const { createRemoteAuthProvider } =
|
||||
await import("@inspector/core/mcp/remote/node/tokenAuthProvider.js");
|
||||
|
|
|
|||
|
|
@ -41,3 +41,35 @@ export function isStrictScopeSuperset(
|
|||
}
|
||||
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 {
|
||||
computeScopeUnion,
|
||||
isStrictScopeSuperset,
|
||||
resolveEffectiveGrantedScope,
|
||||
resolvePersistedScopeAfterGrant,
|
||||
} from "../auth/scopes.js";
|
||||
import { stepUpInsufficientScopeMessage } from "../auth/oauthUx.js";
|
||||
import type {
|
||||
|
|
@ -276,10 +278,15 @@ export class OAuthManager {
|
|||
config,
|
||||
authorizationCode,
|
||||
);
|
||||
if (this.pendingAuthorizationScope) {
|
||||
const requestedScope = this.pendingAuthorizationScope;
|
||||
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||||
tokens.scope,
|
||||
requestedScope,
|
||||
);
|
||||
if (scopeToPersist) {
|
||||
await this.oauthConfig.storage!.saveScope(
|
||||
this.getServerUrl(),
|
||||
this.pendingAuthorizationScope,
|
||||
scopeToPersist,
|
||||
);
|
||||
}
|
||||
this.pendingAuthorizationScope = undefined;
|
||||
|
|
@ -315,8 +322,11 @@ export class OAuthManager {
|
|||
throw new Error("Failed to retrieve tokens after authorization");
|
||||
}
|
||||
|
||||
const scopeToPersist =
|
||||
this.pendingAuthorizationScope ?? tokens.scope;
|
||||
const requestedScope = this.pendingAuthorizationScope;
|
||||
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||||
tokens.scope,
|
||||
requestedScope,
|
||||
);
|
||||
if (scopeToPersist) {
|
||||
await provider.saveScope(scopeToPersist);
|
||||
}
|
||||
|
|
@ -431,8 +441,9 @@ export class OAuthManager {
|
|||
* satisfied without an authorization-server round-trip.
|
||||
*
|
||||
* Returns `true` for `insufficient_scope` when stored + token scope cover the
|
||||
* SEP-2350 union. For `token_expired` / `unauthorized` / `invalid_token`,
|
||||
* returns `true` when a usable access token is already in storage.
|
||||
* SEP-2350 union. For `token_expired`, returns `true` when a usable access
|
||||
* token is already in storage. `invalid_token` and `unauthorized` always
|
||||
* return `false` — the resource server explicitly rejected the credential.
|
||||
*/
|
||||
async checkAuthChallengeSatisfied(
|
||||
challenge: AuthChallenge,
|
||||
|
|
@ -450,10 +461,8 @@ export class OAuthManager {
|
|||
|
||||
if (challenge.reason !== "insufficient_scope") {
|
||||
return (
|
||||
challenge.reason === "token_expired" ||
|
||||
challenge.reason === "unauthorized" ||
|
||||
challenge.reason === "invalid_token"
|
||||
) && isAccessTokenUsable(tokens);
|
||||
challenge.reason === "token_expired" && isAccessTokenUsable(tokens)
|
||||
);
|
||||
}
|
||||
|
||||
const enriched = await this.enrichChallengeWithAuthorizationScopes(
|
||||
|
|
@ -467,7 +476,7 @@ export class OAuthManager {
|
|||
return false;
|
||||
}
|
||||
|
||||
const effectiveScope = computeScopeUnion(
|
||||
const effectiveScope = resolveEffectiveGrantedScope(
|
||||
storage.getScope(serverUrl),
|
||||
tokens.scope,
|
||||
);
|
||||
|
|
@ -603,10 +612,17 @@ export class OAuthManager {
|
|||
const silent = await trySilentEmaAuth(config);
|
||||
if (silent.status === "success") {
|
||||
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(
|
||||
this.getServerUrl(),
|
||||
enriched.authorizationScopes.join(" "),
|
||||
scopeToPersist,
|
||||
);
|
||||
}
|
||||
return { kind: "satisfied" };
|
||||
|
|
@ -688,8 +704,13 @@ export class OAuthManager {
|
|||
if (result === "AUTHORIZED") {
|
||||
if (enriched.reason === "insufficient_scope") {
|
||||
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
||||
if (scopeForAuth) {
|
||||
await provider.saveScope(scopeForAuth);
|
||||
const freshTokens = await provider.tokens();
|
||||
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||||
freshTokens?.scope,
|
||||
scopeForAuth,
|
||||
);
|
||||
if (scopeToPersist) {
|
||||
await provider.saveScope(scopeToPersist);
|
||||
}
|
||||
return { kind: "satisfied" };
|
||||
}
|
||||
|
|
@ -799,8 +820,13 @@ export class OAuthManager {
|
|||
return null;
|
||||
}
|
||||
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
||||
if (scopeForAuth) {
|
||||
await provider.saveScope(scopeForAuth);
|
||||
const freshTokens = await provider.tokens();
|
||||
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||||
freshTokens?.scope,
|
||||
scopeForAuth,
|
||||
);
|
||||
if (scopeToPersist) {
|
||||
await provider.saveScope(scopeToPersist);
|
||||
}
|
||||
return { kind: "satisfied" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,7 +173,12 @@ export class RemoteSession {
|
|||
}
|
||||
|
||||
cancelRequestWait(requestId: string | number): void {
|
||||
const wait = this.requestWaits.get(requestId);
|
||||
if (!wait) {
|
||||
return;
|
||||
}
|
||||
this.requestWaits.delete(requestId);
|
||||
wait.reject(new Error("MCP request wait cancelled"));
|
||||
}
|
||||
|
||||
rejectActiveRequestWaits(error: Error): void {
|
||||
|
|
|
|||
|
|
@ -578,7 +578,12 @@ export class RemoteClientTransport implements Transport {
|
|||
}
|
||||
|
||||
private cancelSseResponseWait(requestId: string | number): void {
|
||||
const wait = this.sseResponseWaits.get(requestId);
|
||||
if (!wait) {
|
||||
return;
|
||||
}
|
||||
this.sseResponseWaits.delete(requestId);
|
||||
wait.reject(new Error("SSE response wait cancelled"));
|
||||
}
|
||||
|
||||
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 |
|
||||
| **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).
|
||||
|
||||
|
|
@ -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()`) |
|
||||
| `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
|
||||
|
||||
|
|
|
|||
|
|
@ -837,6 +837,13 @@ export interface ScopeRequirementRegistry {
|
|||
tools: Map<string, string[]>;
|
||||
resources: 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. */
|
||||
|
|
@ -847,6 +854,7 @@ export function buildScopeRequirementRegistry(
|
|||
tools: new Map(),
|
||||
resources: new Map(),
|
||||
prompts: new Map(),
|
||||
resourceTemplates: new Map(),
|
||||
};
|
||||
|
||||
for (const tool of config.tools ?? []) {
|
||||
|
|
@ -864,6 +872,11 @@ export function buildScopeRequirementRegistry(
|
|||
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;
|
||||
}
|
||||
|
|
@ -874,7 +887,8 @@ export function scopeRequirementRegistryHasEntries(
|
|||
return (
|
||||
registry.tools.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);
|
||||
} else if (method === "resources/read") {
|
||||
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") {
|
||||
requiredScopes = registry.prompts.get(target);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue