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
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:
parent
f915889fc4
commit
7fecebed5e
11 changed files with 1620 additions and 601 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"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.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<NetworkEntry entry={tokenRequest} isListExpanded={false} />,
|
||||
);
|
||||
expect(screen.getByText("Token")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not label transport requests or unrecognized auth URLs", () => {
|
||||
renderWithMantine(
|
||||
<NetworkEntry entry={baseEntry} isListExpanded={false} />,
|
||||
);
|
||||
expect(screen.queryByText("Token")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Discovery")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<Badge color="violet" variant="light">
|
||||
{oauthNetworkPhaseLabel(oauthPhase)}
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
const metaBadges = (
|
||||
<>
|
||||
{entry.duration != null && (
|
||||
|
|
@ -278,6 +292,7 @@ export function NetworkEntry({
|
|||
</TimestampText>
|
||||
<MethodBadge method={entry.method} />
|
||||
<CategoryBadge category={entry.category} />
|
||||
{phaseBadge}
|
||||
</HeaderCluster>
|
||||
<ControlsCluster>{metaBadges}</ControlsCluster>
|
||||
</HeaderRow>
|
||||
|
|
@ -306,6 +321,7 @@ export function NetworkEntry({
|
|||
</TimestampText>
|
||||
<MethodBadge method={entry.method} />
|
||||
<CategoryBadge category={entry.category} />
|
||||
{phaseBadge}
|
||||
<CopyButton value={entry.url} />
|
||||
<UrlText>{entry.url}</UrlText>
|
||||
</Group>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onOAuthChange={onOAuthChange}
|
||||
settings={emptySettings}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label="Insufficient-scope response"
|
||||
description="On a 403 insufficient_scope challenge (SEP-2350): re-authorize with the accumulated scope union, or surface the error to you."
|
||||
data={[
|
||||
{
|
||||
value: "reauthorize",
|
||||
label: "Re-authorize (union scopes)",
|
||||
},
|
||||
{ value: "throw", label: "Throw (surface the error)" },
|
||||
]}
|
||||
value={settings.oauthOnInsufficientScope ?? "reauthorize"}
|
||||
onChange={(value) =>
|
||||
onOAuthChange({
|
||||
...currentOAuth(),
|
||||
onInsufficientScope:
|
||||
value === "throw" ? "throw" : "reauthorize",
|
||||
})
|
||||
}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{onClearStoredOAuth ? (
|
||||
<Flex align="flex-start" gap="sm" wrap="wrap">
|
||||
<ClearStoredOAuthButton onClick={onClearStoredOAuth}>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,29 @@ describe("ServerSettingsModal", () => {
|
|||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps the OAuth insufficient-scope policy into settings (SEP-2350)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsModal
|
||||
opened
|
||||
settings={initialSettings}
|
||||
serverType="streamable-http"
|
||||
isStdio={false}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /OAuth Settings/i }));
|
||||
await user.click(
|
||||
screen.getByRole("textbox", { name: /Insufficient-scope/i }),
|
||||
);
|
||||
await user.click(screen.getByText("Throw (surface the error)"));
|
||||
expect(onSettingsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ oauthOnInsufficientScope: "throw" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls onSettingsChange when adding a header after expanding the section", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
|
|
|
|||
|
|
@ -150,6 +150,10 @@ export function ServerSettingsModal({
|
|||
oauthClientSecret: oauth.clientSecret,
|
||||
oauthScopes: oauth.scopes,
|
||||
enterpriseManaged: oauth.enterpriseManaged ? true : undefined,
|
||||
// SEP-2350: persist only the non-default ('throw') so unset servers keep
|
||||
// the SDK's `reauthorize` behavior without writing a spurious field.
|
||||
oauthOnInsufficientScope:
|
||||
oauth.onInsufficientScope === "throw" ? "throw" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ describe("StepUpAuthModal", () => {
|
|||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists additional scopes from the challenge, not the SEP-2350 union", () => {
|
||||
it("visualizes the SEP-2350 scope union, tagging carried-over vs newly-required scopes", () => {
|
||||
renderWithMantine(
|
||||
<StepUpAuthModal
|
||||
opened
|
||||
|
|
@ -40,9 +40,14 @@ describe("StepUpAuthModal", () => {
|
|||
screen.getByText(/Additional permissions required/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/get_temp/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Scopes to authorize")).toBeInTheDocument();
|
||||
// The whole union renders — challenged scope tagged "new", the rest carried
|
||||
// over from the prior grant and tagged "already granted".
|
||||
expect(screen.getByText("weather:read")).toBeInTheDocument();
|
||||
expect(screen.queryByText("tools:read")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("mcp")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("tools:read")).toBeInTheDocument();
|
||||
expect(screen.getByText("mcp")).toBeInTheDocument();
|
||||
expect(screen.getByText("new")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("already granted")).toHaveLength(2);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/redirected to authorize, then returned to the inspector/i,
|
||||
|
|
@ -50,6 +55,22 @@ describe("StepUpAuthModal", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows only the challenged scopes when there is no prior grant to union", () => {
|
||||
renderWithMantine(
|
||||
<StepUpAuthModal
|
||||
opened
|
||||
challenge={stepUpChallenge}
|
||||
// Union equals the challenged scope — nothing carried over.
|
||||
authorizationScopes={["weather:read"]}
|
||||
onAuthorize={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Additional scopes needed")).toBeInTheDocument();
|
||||
expect(screen.getByText("weather:read")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Scopes to authorize")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses EMA copy when enterpriseManaged is true", () => {
|
||||
renderWithMantine(
|
||||
<StepUpAuthModal
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, Group, List, Modal, Stack, Text } from "@mantine/core";
|
||||
import { Badge, Button, Group, List, Modal, Stack, Text } from "@mantine/core";
|
||||
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||
import {
|
||||
stepUpAdditionalScopes,
|
||||
|
|
@ -20,6 +20,12 @@ export interface StepUpAuthModalProps {
|
|||
|
||||
const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" });
|
||||
|
||||
const ScopeToken = Text.withProps({
|
||||
component: "span",
|
||||
ff: "monospace",
|
||||
size: "sm",
|
||||
});
|
||||
|
||||
export function StepUpAuthModal({
|
||||
opened,
|
||||
challenge,
|
||||
|
|
@ -32,6 +38,16 @@ export function StepUpAuthModal({
|
|||
const ema = enterpriseManaged === true;
|
||||
const handleAuthorize = () => void onAuthorize();
|
||||
|
||||
// SEP-2350: `authorizationScopes` is the union the client will re-authorize
|
||||
// with (previously requested ∪ challenged). Split it so the user sees exactly
|
||||
// what carries over versus what the failing operation newly requires.
|
||||
const additionalSet = new Set(additionalScopes);
|
||||
const unionScopes = authorizationScopes ?? additionalScopes;
|
||||
const carriedOverScopes = unionScopes.filter(
|
||||
(scope) => !additionalSet.has(scope),
|
||||
);
|
||||
const showUnion = !ema && carriedOverScopes.length > 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
|
|
@ -50,7 +66,34 @@ export function StepUpAuthModal({
|
|||
)}{" "}
|
||||
{stepUpFollowUpMessage({ enterpriseManaged: ema })}
|
||||
</Text>
|
||||
{additionalScopes.length > 0 ? (
|
||||
{showUnion ? (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Scopes to authorize
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Re-authorizing with the union of your previously granted scopes
|
||||
and the ones this operation requires, so the new token keeps every
|
||||
grant (SEP-2350).
|
||||
</Text>
|
||||
<List size="sm" spacing="xs" listStyleType="none">
|
||||
{unionScopes.map((scope) => (
|
||||
<List.Item key={scope}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ScopeToken>{scope}</ScopeToken>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={additionalSet.has(scope) ? "blue" : "gray"}
|
||||
>
|
||||
{additionalSet.has(scope) ? "new" : "already granted"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
) : additionalScopes.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{ema
|
||||
|
|
@ -60,20 +103,10 @@ export function StepUpAuthModal({
|
|||
<List size="sm" spacing="xs">
|
||||
{additionalScopes.map((scope) => (
|
||||
<List.Item key={scope}>
|
||||
<Text component="span" ff="monospace" size="sm">
|
||||
{scope}
|
||||
</Text>
|
||||
<ScopeToken>{scope}</ScopeToken>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
{!ema &&
|
||||
authorizationScopes &&
|
||||
authorizationScopes.length > additionalScopes.length ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
The authorization server may also show scopes you already
|
||||
granted during sign-in.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
<Actions>
|
||||
|
|
|
|||
Loading…
Reference in a new issue