diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 86445f0a..01b05bc5 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -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 => { 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 diff --git a/clients/web/src/test/core/auth/scopes.test.ts b/clients/web/src/test/core/auth/scopes.test.ts index ebb474c1..bc906661 100644 --- a/clients/web/src/test/core/auth/scopes.test.ts +++ b/clients/web/src/test/core/auth/scopes.test.ts @@ -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", + ); + }); + }); }); diff --git a/clients/web/src/test/core/mcp/inspectorClient-direct-auth-recovery.test.ts b/clients/web/src/test/core/mcp/inspectorClient-direct-auth-recovery.test.ts new file mode 100644 index 00000000..95d36663 --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-direct-auth-recovery.test.ts @@ -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; + withDirectAuthRecovery: ( + operation: () => Promise, + context?: { method?: string; toolName?: string }, + attempt?: number, + ) => Promise; + }; + 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); + }); +}); diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 26294217..1ace33db 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -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({ diff --git a/clients/web/src/test/core/mcp/test-server-scope.test.ts b/clients/web/src/test/core/mcp/test-server-scope.test.ts index ce5f8cce..903ab5f2 100644 --- a/clients/web/src/test/core/mcp/test-server-scope.test.ts +++ b/clients/web/src/test/core/mcp/test-server-scope.test.ts @@ -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(), diff --git a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts index e1dca9fe..960273a2 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts @@ -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"); diff --git a/core/auth/scopes.ts b/core/auth/scopes.ts index 2ec9aa6d..d858bd0f 100644 --- a/core/auth/scopes.ts +++ b/core/auth/scopes.ts @@ -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); +} diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 96c95e19..880f8c44 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -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" }; } diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index 760ed631..83abd5dd 100644 --- a/core/mcp/remote/node/remote-session.ts +++ b/core/mcp/remote/node/remote-session.ts @@ -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 { diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 306d91aa..b9fde2f8 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -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 { diff --git a/specification/v2_auth_mid_session.md b/specification/v2_auth_mid_session.md index 4089bfb2..e944f466 100644 --- a/specification/v2_auth_mid_session.md +++ b/specification/v2_auth_mid_session.md @@ -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 diff --git a/test-servers/src/test-server-oauth.ts b/test-servers/src/test-server-oauth.ts index 231ba20a..4ea0ac30 100644 --- a/test-servers/src/test-server-oauth.ts +++ b/test-servers/src/test-server-oauth.ts @@ -837,6 +837,13 @@ export interface ScopeRequirementRegistry { tools: Map; resources: Map; prompts: Map; + resourceTemplates: Map; +} + +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); }