OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#1694)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled

* OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#1625)

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) <noreply@anthropic.com>

* Address @claude review: issuer-stamp source gating + clear/activeIssuer + phase over-match

- SEP-2352: stamp the issuer only when the credential actually came from the
  byIssuer slot (`fromSlot`), not merely when a slot exists. A partially-populated
  slot alongside a surviving legacy field would otherwise stamp a legacy (possibly
  cross-AS) credential with the current issuer, defeating discardIfIssuerMismatch.
- Issuer-scoped clears no longer promote the cleared issuer to `activeIssuer`
  (updateIssuerSlot gains a `setActive` flag; save paths keep the promotion).
- oauthNetworkPhase matches register/token/authorize as the final path segment
  (trailing slash tolerated) so `/token/refresh` or `/api/register/foo` no longer
  over-match.
- Tests for all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-16 16:52:53 -04:00 • committed by GitHub
commit 7fecebed5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1620 additions and 601 deletions

View file

@ -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(
<ClientSettingsForm
settings={EMPTY_CLIENT_SETTINGS}
expandedSections={[]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
registrationError={{
error: "invalid_redirect_uri",
errorDescription: "Loopback redirect URIs are not permitted",
status: 400,
}}
/>,
);
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(
<ClientSettingsForm
settings={EMPTY_CLIENT_SETTINGS}
expandedSections={[]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
registrationError={{}}
/>,
);
expect(
screen.getByText(
"The authorization server rejected client registration.",
),
).toBeInTheDocument();
});
it("omits the rejection alert when there is no registration error", () => {
renderWithMantine(
<ClientSettingsForm
settings={EMPTY_CLIENT_SETTINGS}
expandedSections={[]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(
screen.queryByText("Client registration was rejected"),
).not.toBeInTheDocument();
});
it("reflects DCR's deprecated-in-favor-of-CIMD status in the CIMD section", () => {
renderWithMantine(
<ClientSettingsForm
settings={EMPTY_CLIENT_SETTINGS}
expandedSections={["cimd"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.getByText(/deprecated in favor of/i)).toBeInTheDocument();
});
});

View file

@ -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<ClientSettingsFormValues>) {
onSettingsChange((prev) => ({ ...prev, ...partial }));
@ -92,174 +113,216 @@ export function ClientSettingsForm({
emaIdpLoginState !== "none";
return (
<Accordion
multiple
value={expandedSections}
onChange={(value) =>
onExpandedSectionsChange(value as ClientSettingsSection[])
}
variant="separated"
>
<Accordion.Item value="ema">
<Accordion.Control>Enterprise-Managed Authorization</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<Checkbox
label="Enable enterprise IdP configuration"
description="Enterprise-managed authorization signs you in through your organization's IdP instead of each MCP server's own OAuth login. Enable enterprise-managed authorization on participating MCP server's settings."
checked={settings.emaEnabled}
onChange={(e) => patch({ emaEnabled: e.currentTarget.checked })}
/>
{settings.emaEnabled && (
<>
<HintText>
Register this redirect URI with your IdP:{" "}
{typeof window !== "undefined"
? `${window.location.origin}/oauth/callback`
: "http://localhost:6274/oauth/callback"}
</HintText>
<TextInput
label="Issuer"
description="Your enterprise IdP issuer URL."
value={settings.issuer}
onChange={(e) => patch({ issuer: e.currentTarget.value })}
onBlur={() => setIssuerTouched(true)}
error={showIssuerError}
rightSectionPointerEvents="auto"
rightSection={
settings.issuer ? (
<ClearButton onClick={() => patch({ issuer: "" })} />
) : null
}
/>
<TextInput
label="Client ID"
description="Client id registered with your enterprise IdP."
value={settings.clientId}
onChange={(e) => patch({ clientId: e.currentTarget.value })}
error={showClientIdError}
rightSectionPointerEvents="auto"
rightSection={
settings.clientId ? (
<ClearButton onClick={() => patch({ clientId: "" })} />
) : null
}
/>
<TextInput
label="Client Secret"
description="Client secret for your enterprise IdP (if required)."
type="password"
value={settings.clientSecret}
onChange={(e) =>
patch({ clientSecret: e.currentTarget.value })
}
rightSectionPointerEvents="auto"
rightSection={
settings.clientSecret ? (
<ClearButton
onClick={() => patch({ clientSecret: "" })}
/>
) : null
}
/>
{settings.issuer.trim() !== "" && (
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Stack gap={4} flex={1}>
<Text size="sm" fw={500}>
IdP sign-in
</Text>
{emaIdpLoginState === "logged_in" ? (
<>
<Badge w="fit-content" color="green" variant="light">
Signed in
</Badge>
<HintText>
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.
</HintText>
</>
) : emaIdpLoginState === "expired" ? (
<>
<Badge w="fit-content" color="yellow" variant="light">
Session expired
</Badge>
<HintText>
Your cached IdP session has expired. The next
connect to an EMA-enabled server will prompt for IdP
login.
</HintText>
</>
) : (
<HintText>
Not signed in to your enterprise IdP. Connecting to an
EMA-enabled MCP server will open IdP login.
</HintText>
)}
</Stack>
{showIdpSession && onEmaIdpLogout ? (
<Button
variant="light"
color="red"
size="compact-sm"
onClick={onEmaIdpLogout}
>
Sign out
</Button>
) : null}
</Group>
)}
</>
)}
<Stack gap="md">
{registrationError && (
<Alert
color="red"
variant="light"
title="Client registration was rejected"
>
<Stack gap={4}>
<Text size="sm">
{registrationRejectionSummary(registrationError)}
</Text>
<HintText>
Inspector registers as a native client (application_type
&quot;native&quot;) 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.
</HintText>
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="cimd">
<Accordion.Control>OAuth Client ID Metadata Document</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<Checkbox
label="Use Client ID Metadata Document"
description="When the authorization server supports CIMD, Inspector uses this metadata document URL as the client id. The server fetches and verifies the document during OAuth."
checked={settings.cimdEnabled}
onChange={(e) => patch({ cimdEnabled: e.currentTarget.checked })}
/>
{settings.cimdEnabled && (
<>
<HintText>
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"}
</HintText>
<TextInput
label="Client ID metadata document URL"
description="Public HTTPS URL of your OAuth client metadata JSON document."
value={settings.clientMetadataUrl}
onChange={(e) =>
patch({ clientMetadataUrl: e.currentTarget.value })
}
onBlur={() => setClientMetadataUrlTouched(true)}
error={showClientMetadataUrlError}
rightSectionPointerEvents="auto"
rightSection={
settings.clientMetadataUrl ? (
<ClearButton
onClick={() => patch({ clientMetadataUrl: "" })}
/>
) : null
}
/>
</>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Alert>
)}
<Accordion
multiple
value={expandedSections}
onChange={(value) =>
onExpandedSectionsChange(value as ClientSettingsSection[])
}
variant="separated"
>
<Accordion.Item value="ema">
<Accordion.Control>
Enterprise-Managed Authorization
</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<Checkbox
label="Enable enterprise IdP configuration"
description="Enterprise-managed authorization signs you in through your organization's IdP instead of each MCP server's own OAuth login. Enable enterprise-managed authorization on participating MCP server's settings."
checked={settings.emaEnabled}
onChange={(e) => patch({ emaEnabled: e.currentTarget.checked })}
/>
{settings.emaEnabled && (
<>
<HintText>
Register this redirect URI with your IdP:{" "}
{typeof window !== "undefined"
? `${window.location.origin}/oauth/callback`
: "http://localhost:6274/oauth/callback"}
</HintText>
<TextInput
label="Issuer"
description="Your enterprise IdP issuer URL."
value={settings.issuer}
onChange={(e) => patch({ issuer: e.currentTarget.value })}
onBlur={() => setIssuerTouched(true)}
error={showIssuerError}
rightSectionPointerEvents="auto"
rightSection={
settings.issuer ? (
<ClearButton onClick={() => patch({ issuer: "" })} />
) : null
}
/>
<TextInput
label="Client ID"
description="Client id registered with your enterprise IdP."
value={settings.clientId}
onChange={(e) => patch({ clientId: e.currentTarget.value })}
error={showClientIdError}
rightSectionPointerEvents="auto"
rightSection={
settings.clientId ? (
<ClearButton onClick={() => patch({ clientId: "" })} />
) : null
}
/>
<TextInput
label="Client Secret"
description="Client secret for your enterprise IdP (if required)."
type="password"
value={settings.clientSecret}
onChange={(e) =>
patch({ clientSecret: e.currentTarget.value })
}
rightSectionPointerEvents="auto"
rightSection={
settings.clientSecret ? (
<ClearButton
onClick={() => patch({ clientSecret: "" })}
/>
) : null
}
/>
{settings.issuer.trim() !== "" && (
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Stack gap={4} flex={1}>
<Text size="sm" fw={500}>
IdP sign-in
</Text>
{emaIdpLoginState === "logged_in" ? (
<>
<Badge
w="fit-content"
color="green"
variant="light"
>
Signed in
</Badge>
<HintText>
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.
</HintText>
</>
) : emaIdpLoginState === "expired" ? (
<>
<Badge
w="fit-content"
color="yellow"
variant="light"
>
Session expired
</Badge>
<HintText>
Your cached IdP session has expired. The next
connect to an EMA-enabled server will prompt for
IdP login.
</HintText>
</>
) : (
<HintText>
Not signed in to your enterprise IdP. Connecting to
an EMA-enabled MCP server will open IdP login.
</HintText>
)}
</Stack>
{showIdpSession && onEmaIdpLogout ? (
<Button
variant="light"
color="red"
size="compact-sm"
onClick={onEmaIdpLogout}
>
Sign out
</Button>
) : null}
</Group>
)}
</>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="cimd">
<Accordion.Control>
OAuth Client ID Metadata Document
</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<HintText>
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).
</HintText>
<Checkbox
label="Use Client ID Metadata Document"
description="When the authorization server supports CIMD, Inspector uses this metadata document URL as the client id. The server fetches and verifies the document during OAuth."
checked={settings.cimdEnabled}
onChange={(e) =>
patch({ cimdEnabled: e.currentTarget.checked })
}
/>
{settings.cimdEnabled && (
<>
<HintText>
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"}
</HintText>
<TextInput
label="Client ID metadata document URL"
description="Public HTTPS URL of your OAuth client metadata JSON document."
value={settings.clientMetadataUrl}
onChange={(e) =>
patch({ clientMetadataUrl: e.currentTarget.value })
}
onBlur={() => setClientMetadataUrlTouched(true)}
error={showClientMetadataUrlError}
rightSectionPointerEvents="auto"
rightSection={
settings.clientMetadataUrl ? (
<ClearButton
onClick={() => patch({ clientMetadataUrl: "" })}
/>
) : null
}
/>
</>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Stack>
);
}

View file

@ -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)