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 } /> +