From 5feebf25c204f44c22941cabfdf94bf72baea483 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 16 Jul 2026 13:53:13 -0400 Subject: [PATCH 1/2] OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#1625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-key the persisted OAuth store to (server, issuer) and add the SEP-837/2350/2352 authorization-hardening UX. The SDK v2 auth() drives the SEP-2352 flow off the provider's ctx.issuer; the Inspector's job is to honor it and round-trip the issuer stamp. SEP-2352 — issuer-bound credentials - ServerOAuthState nests issuer-bound credentials under byIssuer[issuer] = { clientInformation, clientRegistrationKind, tokens }, with an activeIssuer pointer answering the transport's ctx-less per-request bearer read. - Fix a latent bug: OAuthStorageBase re-parsed tokens/client-info through OAuthTokensSchema/OAuthClientInformationSchema, which strip the non-RFC `issuer` field and silently defeat the SDK's discardIfIssuerMismatch. The store re-attaches the stamp from the byIssuer key. - BaseOAuthClientProvider threads ctx.issuer and implements discoveryState()/saveDiscoveryState() (activates the SDK's callback-leg AuthorizationServerMismatchError binding check) and invalidateCredentials(). - Lazy, migration-free upgrade: a legacy pre-1625 blob's top-level clientInformation/tokens are returned as an unkeyed fallback until the first issuer-stamped save promotes them into byIssuer. SEP-837 — application_type - DCR declares application_type: "native" (localhost Inspector). - ClientSettingsForm renders RegistrationRejectedError detail and reflects DCR's deprecated-in-favor-of-CIMD (SEP-991) status. SEP-2350 — step-up scope union - Per-server onInsufficientScope (reauthorize | throw) setting wired ServerSettingsForm -> StoredMCPServer.oauth -> StreamableHTTPClientTransport. - StepUpAuthModal visualizes the prior-union-challenged scope set, tagging each scope `new` vs `already granted`. mcpAuth simplification - Delete the hand-rolled authorizeWithoutRefresh (mishandled issuer stamping); forward forceReauthorization/skipIssuerMetadataValidation to native SDK auth() so both the initial and step-up paths get SEP-2352 stamping. Vocabulary + docs - OAuthStep gains cimd_fetch / issuer_comparison / scope_step_up (recorded on a step-up redirect); the Network tab labels auth requests by OAuth phase. - v2_auth_hardening.md gains an as-built status section. Deferred to the era-aware card: the SdkError(EraNegotiationFailed) -> UnauthorizedError unwrap and finishAuth(URLSearchParams) form (only fire under non-legacy negotiation). Closes #1625 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ClientSettingsForm.test.tsx | 70 +++ .../ClientSettingsForm/ClientSettingsForm.tsx | 397 ++++++++++-------- .../clientSettingsValues.ts | 17 + .../groups/NetworkEntry/NetworkEntry.test.tsx | 20 + .../groups/NetworkEntry/NetworkEntry.tsx | 16 + .../ServerSettingsForm.test.tsx | 20 + .../ServerSettingsForm/ServerSettingsForm.tsx | 22 + .../ServerSettingsModal.test.tsx | 23 + .../ServerSettingsModal.tsx | 4 + .../StepUpAuthModal/StepUpAuthModal.test.tsx | 27 +- .../StepUpAuthModal/StepUpAuthModal.tsx | 59 ++- .../test/core/auth/connection-state.test.ts | 3 + .../web/src/test/core/auth/mcpAuth.test.ts | 247 ++--------- .../web/src/test/core/auth/providers.test.ts | 104 ++++- .../test/core/auth/storage-browser.test.ts | 155 +++++++ .../src/test/core/mcp/oauthManager.test.ts | 6 + .../web/src/test/core/mcp/serverList.test.ts | 37 ++ .../mcp/inspectorClient-oauth-e2e.test.ts | 16 +- ...torClient-oauth-remote-storage-e2e.test.ts | 16 +- .../integration/mcp/node/transport.test.ts | 32 ++ .../mcp/remote/servers-route.test.ts | 61 +++ .../web/src/utils/oauthNetworkPhase.test.ts | 59 +++ clients/web/src/utils/oauthNetworkPhase.ts | 59 +++ core/auth/mcpAuth.ts | 148 +------ core/auth/oauth-storage.ts | 232 ++++++++-- core/auth/providers.ts | 86 +++- core/auth/storage.ts | 62 ++- core/auth/store.ts | 45 +- core/auth/types.ts | 11 +- core/mcp/node/transport.ts | 5 + core/mcp/oauthManager.ts | 12 +- core/mcp/remote/node/server.ts | 17 + core/mcp/serverList.ts | 13 +- core/mcp/types.ts | 16 + specification/v2_auth_hardening.md | 11 + 35 files changed, 1527 insertions(+), 601 deletions(-) create mode 100644 clients/web/src/utils/oauthNetworkPhase.test.ts create mode 100644 clients/web/src/utils/oauthNetworkPhase.ts diff --git a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx index 7f3cbec27..9945bc366 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx @@ -613,3 +613,73 @@ describe("ClientSettingsForm interactions", () => { ).not.toBeInTheDocument(); }); }); + +describe("ClientSettingsForm SEP-837 registration UX", () => { + it("renders a registration-rejection alert with the RFC 7591 error detail", () => { + renderWithMantine( + , + ); + expect( + screen.getByText("Client registration was rejected"), + ).toBeInTheDocument(); + expect( + screen.getByText(/invalid_redirect_uri — .* — HTTP 400/), + ).toBeInTheDocument(); + expect( + screen.getByText(/registers as a native client/i), + ).toBeInTheDocument(); + }); + + it("falls back to a generic message when no RFC 7591 detail is present", () => { + renderWithMantine( + , + ); + expect( + screen.getByText( + "The authorization server rejected client registration.", + ), + ).toBeInTheDocument(); + }); + + it("omits the rejection alert when there is no registration error", () => { + renderWithMantine( + , + ); + expect( + screen.queryByText("Client registration was rejected"), + ).not.toBeInTheDocument(); + }); + + it("reflects DCR's deprecated-in-favor-of-CIMD status in the CIMD section", () => { + renderWithMantine( + , + ); + expect(screen.getByText(/deprecated in favor of/i)).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx index cf89e2a89..c8d8ca772 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx +++ b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Accordion, + Alert, Badge, Button, Checkbox, @@ -14,6 +15,7 @@ import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js"; import { validateClientSettings, type ClientSettingsFormValues, + type OAuthRegistrationError, } from "./clientSettingsValues.js"; export type ClientSettingsSection = "ema" | "cimd"; @@ -36,6 +38,12 @@ export interface ClientSettingsFormProps { * silently dropped without explanation. */ revealErrors?: boolean; + /** + * Last Dynamic Client Registration rejection from an authorization server + * (SEP-837), surfaced so the user can see *why* DCR failed and adjust (switch + * to CIMD, fix the redirect URI). Cleared by the parent on the next attempt. + */ + registrationError?: OAuthRegistrationError; } const HintText = Text.withProps({ @@ -43,6 +51,18 @@ const HintText = Text.withProps({ c: "dimmed", }); +/** Human-readable one-liner for an RFC 7591 DCR rejection. */ +function registrationRejectionSummary(error: OAuthRegistrationError): string { + const parts = [ + error.error, + error.errorDescription, + error.status ? `HTTP ${error.status}` : undefined, + ].filter(Boolean); + return parts.length > 0 + ? parts.join(" — ") + : "The authorization server rejected client registration."; +} + export function ClientSettingsForm({ settings, expandedSections, @@ -51,6 +71,7 @@ export function ClientSettingsForm({ emaIdpLoginState = "none", onEmaIdpLogout, revealErrors = false, + registrationError, }: ClientSettingsFormProps) { function patch(partial: Partial) { onSettingsChange((prev) => ({ ...prev, ...partial })); @@ -92,174 +113,216 @@ export function ClientSettingsForm({ emaIdpLoginState !== "none"; return ( - - onExpandedSectionsChange(value as ClientSettingsSection[]) - } - variant="separated" - > - - Enterprise-Managed Authorization - - - patch({ emaEnabled: e.currentTarget.checked })} - /> - {settings.emaEnabled && ( - <> - - Register this redirect URI with your IdP:{" "} - {typeof window !== "undefined" - ? `${window.location.origin}/oauth/callback` - : "http://localhost:6274/oauth/callback"} - - patch({ issuer: e.currentTarget.value })} - onBlur={() => setIssuerTouched(true)} - error={showIssuerError} - rightSectionPointerEvents="auto" - rightSection={ - settings.issuer ? ( - patch({ issuer: "" })} /> - ) : null - } - /> - patch({ clientId: e.currentTarget.value })} - error={showClientIdError} - rightSectionPointerEvents="auto" - rightSection={ - settings.clientId ? ( - patch({ clientId: "" })} /> - ) : null - } - /> - - patch({ clientSecret: e.currentTarget.value }) - } - rightSectionPointerEvents="auto" - rightSection={ - settings.clientSecret ? ( - patch({ clientSecret: "" })} - /> - ) : null - } - /> - {settings.issuer.trim() !== "" && ( - - - - IdP sign-in - - {emaIdpLoginState === "logged_in" ? ( - <> - - Signed in - - - Your enterprise IdP session is active. Connecting to - EMA-enabled MCP servers will not prompt for IdP - login until you sign out or the session expires. - - - ) : emaIdpLoginState === "expired" ? ( - <> - - Session expired - + + {registrationError && ( + + + + {registrationRejectionSummary(registrationError)} + + + Inspector registers as a native client (application_type + "native") so authorization servers accept its localhost + redirect URI (SEP-837). If the server still rejects Dynamic Client + Registration, use a Client ID Metadata Document (below) or a + preregistered client instead. + + + + )} + + onExpandedSectionsChange(value as ClientSettingsSection[]) + } + variant="separated" + > + + + Enterprise-Managed Authorization + + + + patch({ emaEnabled: e.currentTarget.checked })} + /> + {settings.emaEnabled && ( + <> + + Register this redirect URI with your IdP:{" "} + {typeof window !== "undefined" + ? `${window.location.origin}/oauth/callback` + : "http://localhost:6274/oauth/callback"} + + patch({ issuer: e.currentTarget.value })} + onBlur={() => setIssuerTouched(true)} + error={showIssuerError} + rightSectionPointerEvents="auto" + rightSection={ + settings.issuer ? ( + patch({ issuer: "" })} /> + ) : null + } + /> + patch({ clientId: e.currentTarget.value })} + error={showClientIdError} + rightSectionPointerEvents="auto" + rightSection={ + settings.clientId ? ( + patch({ clientId: "" })} /> + ) : null + } + /> + + patch({ clientSecret: e.currentTarget.value }) + } + rightSectionPointerEvents="auto" + rightSection={ + settings.clientSecret ? ( + patch({ clientSecret: "" })} + /> + ) : null + } + /> + {settings.issuer.trim() !== "" && ( + + + + IdP sign-in + + {emaIdpLoginState === "logged_in" ? ( + <> + + Signed in + + + Your enterprise IdP session is active. Connecting + to EMA-enabled MCP servers will not prompt for IdP + login until you sign out or the session expires. + + + ) : emaIdpLoginState === "expired" ? ( + <> + + Session expired + + + Your cached IdP session has expired. The next + connect to an EMA-enabled server will prompt for + IdP login. + + + ) : ( - Your cached IdP session has expired. The next - connect to an EMA-enabled server will prompt for IdP - login. + Not signed in to your enterprise IdP. Connecting to + an EMA-enabled MCP server will open IdP login. - - ) : ( - - Not signed in to your enterprise IdP. Connecting to an - EMA-enabled MCP server will open IdP login. - - )} - - {showIdpSession && onEmaIdpLogout ? ( - - ) : null} - - )} - - )} - - - - - OAuth Client ID Metadata Document - - - patch({ cimdEnabled: e.currentTarget.checked })} - /> - {settings.cimdEnabled && ( - <> - - The metadata document must be served over HTTPS and list this - redirect URI:{" "} - {typeof window !== "undefined" - ? `${window.location.origin}/oauth/callback` - : "http://localhost:6274/oauth/callback"} - - - patch({ clientMetadataUrl: e.currentTarget.value }) - } - onBlur={() => setClientMetadataUrlTouched(true)} - error={showClientMetadataUrlError} - rightSectionPointerEvents="auto" - rightSection={ - settings.clientMetadataUrl ? ( - patch({ clientMetadataUrl: "" })} - /> - ) : null - } - /> - - )} - - - - + )} + + {showIdpSession && onEmaIdpLogout ? ( + + ) : null} + + )} + + )} + + + + + + OAuth Client ID Metadata Document + + + + + CIMD is the preferred client-identity mechanism in the + 2026-07-28 spec. Dynamic Client Registration still works and is + used by default when CIMD is off, but it is now deprecated in + favor of CIMD (SEP-991). + + + patch({ cimdEnabled: e.currentTarget.checked }) + } + /> + {settings.cimdEnabled && ( + <> + + The metadata document must be served over HTTPS and list + this redirect URI:{" "} + {typeof window !== "undefined" + ? `${window.location.origin}/oauth/callback` + : "http://localhost:6274/oauth/callback"} + + + patch({ clientMetadataUrl: e.currentTarget.value }) + } + onBlur={() => setClientMetadataUrlTouched(true)} + error={showClientMetadataUrlError} + rightSectionPointerEvents="auto" + rightSection={ + settings.clientMetadataUrl ? ( + patch({ clientMetadataUrl: "" })} + /> + ) : null + } + /> + + )} + + + + + ); } diff --git a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts index 5e5923688..28c967070 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts +++ b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts @@ -22,6 +22,23 @@ export interface ClientSettingsErrors { clientMetadataUrl?: string; } +/** + * A Dynamic Client Registration rejection surfaced from the authorization + * server (SEP-837). Parsed from the SDK's `RegistrationRejectedError` — the + * RFC 7591 error JSON (`error` / `error_description`) plus the HTTP status — so + * the form can explain *why* registration failed (commonly a redirect-URI + * constraint that the `application_type: "native"` declaration is meant to + * satisfy) rather than showing a bare failure. + */ +export interface OAuthRegistrationError { + /** RFC 7591 `error` code (e.g. `invalid_redirect_uri`, `invalid_client_metadata`). */ + error?: string; + /** RFC 7591 `error_description`, if the AS provided one. */ + errorDescription?: string; + /** HTTP status returned by the registration endpoint. */ + status?: number; +} + export interface ValidateClientSettingsOptions { /** * Also flag required fields left blank. Off by default so inline (as-you-type) diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx index e0c09b5af..2d971ac4b 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -289,4 +289,24 @@ describe("NetworkEntry", () => { screen.queryByRole("button", { name: "Reveal secrets in body" }), ).not.toBeInTheDocument(); }); + + it("labels an auth-category request with its OAuth flow phase", () => { + const tokenRequest: FetchRequestEntry = { + ...baseEntry, + url: "https://as.example.com/oauth/token", + category: "auth", + }; + renderWithMantine( + , + ); + expect(screen.getByText("Token")).toBeInTheDocument(); + }); + + it("does not label transport requests or unrecognized auth URLs", () => { + renderWithMantine( + , + ); + expect(screen.queryByText("Token")).not.toBeInTheDocument(); + expect(screen.queryByText("Discovery")).not.toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index f2c7f33fc..939655445 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -19,6 +19,10 @@ import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; import { CategoryBadge } from "../../elements/CategoryBadge/CategoryBadge"; import { maskSecretsInBody } from "../../../utils/maskSecrets"; +import { + oauthNetworkPhase, + oauthNetworkPhaseLabel, +} from "../../../utils/oauthNetworkPhase"; export interface NetworkEntryProps { entry: FetchRequestEntry; @@ -247,6 +251,16 @@ export function NetworkEntry({ setIsExpanded(isListExpanded); }, [isListExpanded]); + // OAuth flow phase for `auth`-category requests (discovery / registration / + // authorize / token), so the Network tab labels the auth conversation. + const oauthPhase = + entry.category === "auth" ? oauthNetworkPhase(entry.url) : undefined; + const phaseBadge = oauthPhase ? ( + + {oauthNetworkPhaseLabel(oauthPhase)} + + ) : null; + const metaBadges = ( <> {entry.duration != null && ( @@ -278,6 +292,7 @@ export function NetworkEntry({ + {phaseBadge} {metaBadges} @@ -306,6 +321,7 @@ export function NetworkEntry({ + {phaseBadge} {entry.url} diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index 7e514bbfa..68b8c8650 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -496,6 +496,26 @@ describe("ServerSettingsForm", () => { }); }); + it("invokes onOAuthChange with the chosen insufficient-scope policy (SEP-2350)", async () => { + const user = userEvent.setup(); + const onOAuthChange = vi.fn(); + renderWithMantine( + , + ); + await user.click( + screen.getByRole("textbox", { name: /Insufficient-scope/i }), + ); + await user.click(screen.getByText("Throw (surface the error)")); + expect(onOAuthChange).toHaveBeenCalledWith( + expect.objectContaining({ onInsufficientScope: "throw" }), + ); + }); + it("invokes onOAuthChange when enterprise-managed is toggled", async () => { const user = userEvent.setup(); const onOAuthChange = vi.fn(); diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index a3fef83e6..f1869431b 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -6,6 +6,7 @@ import { Flex, Group, NumberInput, + Select, Stack, Text, TextInput, @@ -255,6 +256,7 @@ export function ServerSettingsForm({ clientSecret: settings.oauthClientSecret ?? "", scopes: settings.oauthScopes ?? "", enterpriseManaged: settings.enterpriseManaged ?? false, + onInsufficientScope: settings.oauthOnInsufficientScope, }; } @@ -511,6 +513,26 @@ export function ServerSettingsForm({ ) : null } /> +