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
35 changed files with 1620 additions and 601 deletions
|
|
@ -613,3 +613,73 @@ describe("ClientSettingsForm interactions", () => {
|
||||||
).not.toBeInTheDocument();
|
).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 { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
|
@ -14,6 +15,7 @@ import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js";
|
||||||
import {
|
import {
|
||||||
validateClientSettings,
|
validateClientSettings,
|
||||||
type ClientSettingsFormValues,
|
type ClientSettingsFormValues,
|
||||||
|
type OAuthRegistrationError,
|
||||||
} from "./clientSettingsValues.js";
|
} from "./clientSettingsValues.js";
|
||||||
|
|
||||||
export type ClientSettingsSection = "ema" | "cimd";
|
export type ClientSettingsSection = "ema" | "cimd";
|
||||||
|
|
@ -36,6 +38,12 @@ export interface ClientSettingsFormProps {
|
||||||
* silently dropped without explanation.
|
* silently dropped without explanation.
|
||||||
*/
|
*/
|
||||||
revealErrors?: boolean;
|
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({
|
const HintText = Text.withProps({
|
||||||
|
|
@ -43,6 +51,18 @@ const HintText = Text.withProps({
|
||||||
c: "dimmed",
|
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({
|
export function ClientSettingsForm({
|
||||||
settings,
|
settings,
|
||||||
expandedSections,
|
expandedSections,
|
||||||
|
|
@ -51,6 +71,7 @@ export function ClientSettingsForm({
|
||||||
emaIdpLoginState = "none",
|
emaIdpLoginState = "none",
|
||||||
onEmaIdpLogout,
|
onEmaIdpLogout,
|
||||||
revealErrors = false,
|
revealErrors = false,
|
||||||
|
registrationError,
|
||||||
}: ClientSettingsFormProps) {
|
}: ClientSettingsFormProps) {
|
||||||
function patch(partial: Partial<ClientSettingsFormValues>) {
|
function patch(partial: Partial<ClientSettingsFormValues>) {
|
||||||
onSettingsChange((prev) => ({ ...prev, ...partial }));
|
onSettingsChange((prev) => ({ ...prev, ...partial }));
|
||||||
|
|
@ -92,174 +113,216 @@ export function ClientSettingsForm({
|
||||||
emaIdpLoginState !== "none";
|
emaIdpLoginState !== "none";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion
|
<Stack gap="md">
|
||||||
multiple
|
{registrationError && (
|
||||||
value={expandedSections}
|
<Alert
|
||||||
onChange={(value) =>
|
color="red"
|
||||||
onExpandedSectionsChange(value as ClientSettingsSection[])
|
variant="light"
|
||||||
}
|
title="Client registration was rejected"
|
||||||
variant="separated"
|
>
|
||||||
>
|
<Stack gap={4}>
|
||||||
<Accordion.Item value="ema">
|
<Text size="sm">
|
||||||
<Accordion.Control>Enterprise-Managed Authorization</Accordion.Control>
|
{registrationRejectionSummary(registrationError)}
|
||||||
<Accordion.Panel>
|
</Text>
|
||||||
<Stack gap="md">
|
<HintText>
|
||||||
<Checkbox
|
Inspector registers as a native client (application_type
|
||||||
label="Enable enterprise IdP configuration"
|
"native") so authorization servers accept its localhost
|
||||||
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."
|
redirect URI (SEP-837). If the server still rejects Dynamic Client
|
||||||
checked={settings.emaEnabled}
|
Registration, use a Client ID Metadata Document (below) or a
|
||||||
onChange={(e) => patch({ emaEnabled: e.currentTarget.checked })}
|
preregistered client instead.
|
||||||
/>
|
</HintText>
|
||||||
{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>
|
</Stack>
|
||||||
</Accordion.Panel>
|
</Alert>
|
||||||
</Accordion.Item>
|
)}
|
||||||
<Accordion.Item value="cimd">
|
<Accordion
|
||||||
<Accordion.Control>OAuth Client ID Metadata Document</Accordion.Control>
|
multiple
|
||||||
<Accordion.Panel>
|
value={expandedSections}
|
||||||
<Stack gap="md">
|
onChange={(value) =>
|
||||||
<Checkbox
|
onExpandedSectionsChange(value as ClientSettingsSection[])
|
||||||
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."
|
variant="separated"
|
||||||
checked={settings.cimdEnabled}
|
>
|
||||||
onChange={(e) => patch({ cimdEnabled: e.currentTarget.checked })}
|
<Accordion.Item value="ema">
|
||||||
/>
|
<Accordion.Control>
|
||||||
{settings.cimdEnabled && (
|
Enterprise-Managed Authorization
|
||||||
<>
|
</Accordion.Control>
|
||||||
<HintText>
|
<Accordion.Panel>
|
||||||
The metadata document must be served over HTTPS and list this
|
<Stack gap="md">
|
||||||
redirect URI:{" "}
|
<Checkbox
|
||||||
{typeof window !== "undefined"
|
label="Enable enterprise IdP configuration"
|
||||||
? `${window.location.origin}/oauth/callback`
|
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."
|
||||||
: "http://localhost:6274/oauth/callback"}
|
checked={settings.emaEnabled}
|
||||||
</HintText>
|
onChange={(e) => patch({ emaEnabled: e.currentTarget.checked })}
|
||||||
<TextInput
|
/>
|
||||||
label="Client ID metadata document URL"
|
{settings.emaEnabled && (
|
||||||
description="Public HTTPS URL of your OAuth client metadata JSON document."
|
<>
|
||||||
value={settings.clientMetadataUrl}
|
<HintText>
|
||||||
onChange={(e) =>
|
Register this redirect URI with your IdP:{" "}
|
||||||
patch({ clientMetadataUrl: e.currentTarget.value })
|
{typeof window !== "undefined"
|
||||||
}
|
? `${window.location.origin}/oauth/callback`
|
||||||
onBlur={() => setClientMetadataUrlTouched(true)}
|
: "http://localhost:6274/oauth/callback"}
|
||||||
error={showClientMetadataUrlError}
|
</HintText>
|
||||||
rightSectionPointerEvents="auto"
|
<TextInput
|
||||||
rightSection={
|
label="Issuer"
|
||||||
settings.clientMetadataUrl ? (
|
description="Your enterprise IdP issuer URL."
|
||||||
<ClearButton
|
value={settings.issuer}
|
||||||
onClick={() => patch({ clientMetadataUrl: "" })}
|
onChange={(e) => patch({ issuer: e.currentTarget.value })}
|
||||||
/>
|
onBlur={() => setIssuerTouched(true)}
|
||||||
) : null
|
error={showIssuerError}
|
||||||
}
|
rightSectionPointerEvents="auto"
|
||||||
/>
|
rightSection={
|
||||||
</>
|
settings.issuer ? (
|
||||||
)}
|
<ClearButton onClick={() => patch({ issuer: "" })} />
|
||||||
</Stack>
|
) : null
|
||||||
</Accordion.Panel>
|
}
|
||||||
</Accordion.Item>
|
/>
|
||||||
</Accordion>
|
<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;
|
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 {
|
export interface ValidateClientSettingsOptions {
|
||||||
/**
|
/**
|
||||||
* Also flag required fields left blank. Off by default so inline (as-you-type)
|
* 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" }),
|
screen.queryByRole("button", { name: "Reveal secrets in body" }),
|
||||||
).not.toBeInTheDocument();
|
).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 { MethodBadge } from "../../elements/MethodBadge/MethodBadge";
|
||||||
import { CategoryBadge } from "../../elements/CategoryBadge/CategoryBadge";
|
import { CategoryBadge } from "../../elements/CategoryBadge/CategoryBadge";
|
||||||
import { maskSecretsInBody } from "../../../utils/maskSecrets";
|
import { maskSecretsInBody } from "../../../utils/maskSecrets";
|
||||||
|
import {
|
||||||
|
oauthNetworkPhase,
|
||||||
|
oauthNetworkPhaseLabel,
|
||||||
|
} from "../../../utils/oauthNetworkPhase";
|
||||||
|
|
||||||
export interface NetworkEntryProps {
|
export interface NetworkEntryProps {
|
||||||
entry: FetchRequestEntry;
|
entry: FetchRequestEntry;
|
||||||
|
|
@ -247,6 +251,16 @@ export function NetworkEntry({
|
||||||
setIsExpanded(isListExpanded);
|
setIsExpanded(isListExpanded);
|
||||||
}, [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 = (
|
const metaBadges = (
|
||||||
<>
|
<>
|
||||||
{entry.duration != null && (
|
{entry.duration != null && (
|
||||||
|
|
@ -278,6 +292,7 @@ export function NetworkEntry({
|
||||||
</TimestampText>
|
</TimestampText>
|
||||||
<MethodBadge method={entry.method} />
|
<MethodBadge method={entry.method} />
|
||||||
<CategoryBadge category={entry.category} />
|
<CategoryBadge category={entry.category} />
|
||||||
|
{phaseBadge}
|
||||||
</HeaderCluster>
|
</HeaderCluster>
|
||||||
<ControlsCluster>{metaBadges}</ControlsCluster>
|
<ControlsCluster>{metaBadges}</ControlsCluster>
|
||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
|
|
@ -306,6 +321,7 @@ export function NetworkEntry({
|
||||||
</TimestampText>
|
</TimestampText>
|
||||||
<MethodBadge method={entry.method} />
|
<MethodBadge method={entry.method} />
|
||||||
<CategoryBadge category={entry.category} />
|
<CategoryBadge category={entry.category} />
|
||||||
|
{phaseBadge}
|
||||||
<CopyButton value={entry.url} />
|
<CopyButton value={entry.url} />
|
||||||
<UrlText>{entry.url}</UrlText>
|
<UrlText>{entry.url}</UrlText>
|
||||||
</Group>
|
</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 () => {
|
it("invokes onOAuthChange when enterprise-managed is toggled", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onOAuthChange = vi.fn();
|
const onOAuthChange = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
Flex,
|
Flex,
|
||||||
Group,
|
Group,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
|
@ -255,6 +256,7 @@ export function ServerSettingsForm({
|
||||||
clientSecret: settings.oauthClientSecret ?? "",
|
clientSecret: settings.oauthClientSecret ?? "",
|
||||||
scopes: settings.oauthScopes ?? "",
|
scopes: settings.oauthScopes ?? "",
|
||||||
enterpriseManaged: settings.enterpriseManaged ?? false,
|
enterpriseManaged: settings.enterpriseManaged ?? false,
|
||||||
|
onInsufficientScope: settings.oauthOnInsufficientScope,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -511,6 +513,26 @@ export function ServerSettingsForm({
|
||||||
) : null
|
) : 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 ? (
|
{onClearStoredOAuth ? (
|
||||||
<Flex align="flex-start" gap="sm" wrap="wrap">
|
<Flex align="flex-start" gap="sm" wrap="wrap">
|
||||||
<ClearStoredOAuthButton onClick={onClearStoredOAuth}>
|
<ClearStoredOAuthButton onClick={onClearStoredOAuth}>
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,29 @@ describe("ServerSettingsModal", () => {
|
||||||
expect(onClose).toHaveBeenCalled();
|
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 () => {
|
it("calls onSettingsChange when adding a header after expanding the section", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSettingsChange = vi.fn();
|
const onSettingsChange = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,10 @@ export function ServerSettingsModal({
|
||||||
oauthClientSecret: oauth.clientSecret,
|
oauthClientSecret: oauth.clientSecret,
|
||||||
oauthScopes: oauth.scopes,
|
oauthScopes: oauth.scopes,
|
||||||
enterpriseManaged: oauth.enterpriseManaged ? true : undefined,
|
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();
|
).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(
|
renderWithMantine(
|
||||||
<StepUpAuthModal
|
<StepUpAuthModal
|
||||||
opened
|
opened
|
||||||
|
|
@ -40,9 +40,14 @@ describe("StepUpAuthModal", () => {
|
||||||
screen.getByText(/Additional permissions required/i),
|
screen.getByText(/Additional permissions required/i),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText(/get_temp/)).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.getByText("weather:read")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("tools:read")).not.toBeInTheDocument();
|
expect(screen.getByText("tools:read")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("mcp")).not.toBeInTheDocument();
|
expect(screen.getByText("mcp")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("new")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("already granted")).toHaveLength(2);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
screen.getByText(
|
||||||
/redirected to authorize, then returned to the inspector/i,
|
/redirected to authorize, then returned to the inspector/i,
|
||||||
|
|
@ -50,6 +55,22 @@ describe("StepUpAuthModal", () => {
|
||||||
).toBeInTheDocument();
|
).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", () => {
|
it("uses EMA copy when enterpriseManaged is true", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<StepUpAuthModal
|
<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 type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||||
import {
|
import {
|
||||||
stepUpAdditionalScopes,
|
stepUpAdditionalScopes,
|
||||||
|
|
@ -20,6 +20,12 @@ export interface StepUpAuthModalProps {
|
||||||
|
|
||||||
const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" });
|
const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" });
|
||||||
|
|
||||||
|
const ScopeToken = Text.withProps({
|
||||||
|
component: "span",
|
||||||
|
ff: "monospace",
|
||||||
|
size: "sm",
|
||||||
|
});
|
||||||
|
|
||||||
export function StepUpAuthModal({
|
export function StepUpAuthModal({
|
||||||
opened,
|
opened,
|
||||||
challenge,
|
challenge,
|
||||||
|
|
@ -32,6 +38,16 @@ export function StepUpAuthModal({
|
||||||
const ema = enterpriseManaged === true;
|
const ema = enterpriseManaged === true;
|
||||||
const handleAuthorize = () => void onAuthorize();
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
|
|
@ -50,7 +66,34 @@ export function StepUpAuthModal({
|
||||||
)}{" "}
|
)}{" "}
|
||||||
{stepUpFollowUpMessage({ enterpriseManaged: ema })}
|
{stepUpFollowUpMessage({ enterpriseManaged: ema })}
|
||||||
</Text>
|
</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">
|
<Stack gap="xs">
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{ema
|
{ema
|
||||||
|
|
@ -60,20 +103,10 @@ export function StepUpAuthModal({
|
||||||
<List size="sm" spacing="xs">
|
<List size="sm" spacing="xs">
|
||||||
{additionalScopes.map((scope) => (
|
{additionalScopes.map((scope) => (
|
||||||
<List.Item key={scope}>
|
<List.Item key={scope}>
|
||||||
<Text component="span" ff="monospace" size="sm">
|
<ScopeToken>{scope}</ScopeToken>
|
||||||
{scope}
|
|
||||||
</Text>
|
|
||||||
</List.Item>
|
</List.Item>
|
||||||
))}
|
))}
|
||||||
</List>
|
</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>
|
</Stack>
|
||||||
) : null}
|
) : null}
|
||||||
<Actions>
|
<Actions>
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@ function createStorage(
|
||||||
clearIdpSession: vi.fn(),
|
clearIdpSession: vi.fn(),
|
||||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||||
getCodeVerifier: vi.fn(),
|
getCodeVerifier: vi.fn(),
|
||||||
|
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||||
|
saveDiscoveryState: vi.fn(),
|
||||||
|
clearDiscoveryState: vi.fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,8 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import type { OAuthClientProvider } from "@modelcontextprotocol/client";
|
import type { OAuthClientProvider } from "@modelcontextprotocol/client";
|
||||||
|
|
||||||
const {
|
const { sdkAuth } = vi.hoisted(() => ({
|
||||||
sdkAuth,
|
|
||||||
discoverOAuthServerInfo,
|
|
||||||
startAuthorization,
|
|
||||||
selectResourceURL,
|
|
||||||
registerClient,
|
|
||||||
isHttpsUrl,
|
|
||||||
} = vi.hoisted(() => ({
|
|
||||||
sdkAuth: vi.fn(),
|
sdkAuth: vi.fn(),
|
||||||
discoverOAuthServerInfo: vi.fn(),
|
|
||||||
startAuthorization: vi.fn(),
|
|
||||||
selectResourceURL: vi.fn(),
|
|
||||||
registerClient: vi.fn(),
|
|
||||||
isHttpsUrl: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@modelcontextprotocol/client", async (importOriginal) => {
|
vi.mock("@modelcontextprotocol/client", async (importOriginal) => {
|
||||||
|
|
@ -23,20 +11,12 @@ vi.mock("@modelcontextprotocol/client", async (importOriginal) => {
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
auth: sdkAuth,
|
auth: sdkAuth,
|
||||||
discoverOAuthServerInfo,
|
|
||||||
selectResourceURL,
|
|
||||||
startAuthorization,
|
|
||||||
registerClient,
|
|
||||||
isHttpsUrl,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { mcpAuth } from "@inspector/core/auth/mcpAuth.js";
|
import { mcpAuth } from "@inspector/core/auth/mcpAuth.js";
|
||||||
import { InvalidClientMetadataError } from "@inspector/core/auth/mcpAuth.js";
|
|
||||||
|
|
||||||
function makeProvider(
|
function makeProvider(): OAuthClientProvider {
|
||||||
overrides: Partial<OAuthClientProvider> = {},
|
|
||||||
): OAuthClientProvider {
|
|
||||||
return {
|
return {
|
||||||
redirectUrl: "http://localhost/callback",
|
redirectUrl: "http://localhost/callback",
|
||||||
clientMetadata: {
|
clientMetadata: {
|
||||||
|
|
@ -54,47 +34,22 @@ function makeProvider(
|
||||||
redirectToAuthorization: vi.fn(),
|
redirectToAuthorization: vi.fn(),
|
||||||
saveCodeVerifier: vi.fn(),
|
saveCodeVerifier: vi.fn(),
|
||||||
codeVerifier: vi.fn().mockReturnValue("verifier"),
|
codeVerifier: vi.fn().mockReturnValue("verifier"),
|
||||||
...overrides,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Arrange the discovery + authorization mocks used by the
|
|
||||||
* `forceReauthorization` (authorize-without-refresh) path.
|
|
||||||
*/
|
|
||||||
function stubForcePath(
|
|
||||||
discovery: Partial<{
|
|
||||||
authorizationServerUrl: string;
|
|
||||||
authorizationServerMetadata: Record<string, unknown>;
|
|
||||||
resourceMetadata: unknown;
|
|
||||||
}> = {},
|
|
||||||
): void {
|
|
||||||
discoverOAuthServerInfo.mockResolvedValue({
|
|
||||||
authorizationServerUrl: "https://as.example.com",
|
|
||||||
authorizationServerMetadata: { issuer: "https://as.example.com" },
|
|
||||||
resourceMetadata: undefined,
|
|
||||||
...discovery,
|
|
||||||
});
|
|
||||||
selectResourceURL.mockResolvedValue(undefined);
|
|
||||||
startAuthorization.mockResolvedValue({
|
|
||||||
authorizationUrl: new URL("https://as.example.com/authorize"),
|
|
||||||
codeVerifier: "cv",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("mcpAuth", () => {
|
describe("mcpAuth", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
isHttpsUrl.mockReturnValue(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("delegates to SDK auth() when forceReauthorization is not set", async () => {
|
it("forwards the authorization-code exchange (incl. iss) to SDK auth()", async () => {
|
||||||
sdkAuth.mockResolvedValue("AUTHORIZED");
|
sdkAuth.mockResolvedValue("AUTHORIZED");
|
||||||
const provider = makeProvider();
|
const provider = makeProvider();
|
||||||
|
|
||||||
const result = await mcpAuth(provider, {
|
const result = await mcpAuth(provider, {
|
||||||
serverUrl: "https://mcp.example.com",
|
serverUrl: "https://mcp.example.com",
|
||||||
authorizationCode: "code",
|
authorizationCode: "code",
|
||||||
|
iss: "https://as.example.com",
|
||||||
fetchFn: fetch,
|
fetchFn: fetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -102,14 +57,38 @@ describe("mcpAuth", () => {
|
||||||
expect(sdkAuth).toHaveBeenCalledWith(provider, {
|
expect(sdkAuth).toHaveBeenCalledWith(provider, {
|
||||||
serverUrl: "https://mcp.example.com",
|
serverUrl: "https://mcp.example.com",
|
||||||
authorizationCode: "code",
|
authorizationCode: "code",
|
||||||
|
iss: "https://as.example.com",
|
||||||
scope: undefined,
|
scope: undefined,
|
||||||
resourceMetadataUrl: undefined,
|
resourceMetadataUrl: undefined,
|
||||||
fetchFn: fetch,
|
fetchFn: fetch,
|
||||||
|
skipIssuerMetadataValidation: undefined,
|
||||||
|
forceReauthorization: undefined,
|
||||||
});
|
});
|
||||||
expect(discoverOAuthServerInfo).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects forceReauthorization combined with authorizationCode", async () => {
|
it("forwards forceReauthorization + skipIssuerMetadataValidation to SDK auth()", async () => {
|
||||||
|
sdkAuth.mockResolvedValue("REDIRECT");
|
||||||
|
const provider = makeProvider();
|
||||||
|
|
||||||
|
const result = await mcpAuth(provider, {
|
||||||
|
serverUrl: "https://mcp.example.com",
|
||||||
|
scope: "mcp weather:read",
|
||||||
|
forceReauthorization: true,
|
||||||
|
skipIssuerMetadataValidation: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe("REDIRECT");
|
||||||
|
expect(sdkAuth).toHaveBeenCalledWith(
|
||||||
|
provider,
|
||||||
|
expect.objectContaining({
|
||||||
|
scope: "mcp weather:read",
|
||||||
|
forceReauthorization: true,
|
||||||
|
skipIssuerMetadataValidation: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects forceReauthorization combined with authorizationCode without calling SDK auth()", async () => {
|
||||||
const provider = makeProvider();
|
const provider = makeProvider();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -122,192 +101,14 @@ describe("mcpAuth", () => {
|
||||||
"forceReauthorization cannot be combined with authorizationCode",
|
"forceReauthorization cannot be combined with authorizationCode",
|
||||||
);
|
);
|
||||||
expect(sdkAuth).not.toHaveBeenCalled();
|
expect(sdkAuth).not.toHaveBeenCalled();
|
||||||
expect(discoverOAuthServerInfo).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses discovery + startAuthorization when forceReauthorization is true", async () => {
|
it("propagates the SDK auth() rejection", async () => {
|
||||||
stubForcePath();
|
sdkAuth.mockRejectedValue(new Error("discovery failed"));
|
||||||
const provider = makeProvider();
|
const provider = makeProvider();
|
||||||
|
|
||||||
const result = await mcpAuth(provider, {
|
|
||||||
serverUrl: "https://mcp.example.com",
|
|
||||||
scope: "mcp weather:read",
|
|
||||||
forceReauthorization: true,
|
|
||||||
fetchFn: fetch,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe("REDIRECT");
|
|
||||||
expect(sdkAuth).not.toHaveBeenCalled();
|
|
||||||
expect(startAuthorization).toHaveBeenCalled();
|
|
||||||
expect(provider.saveCodeVerifier).toHaveBeenCalledWith("cv");
|
|
||||||
expect(provider.redirectToAuthorization).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists discovery state and forwards the selected resource + state", async () => {
|
|
||||||
stubForcePath({
|
|
||||||
resourceMetadata: { resource: "https://mcp.example.com" },
|
|
||||||
});
|
|
||||||
selectResourceURL.mockResolvedValue(new URL("https://mcp.example.com/r"));
|
|
||||||
const saveDiscoveryState = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const state = vi.fn().mockResolvedValue("state-123");
|
|
||||||
const provider = makeProvider({ saveDiscoveryState, state });
|
|
||||||
|
|
||||||
await mcpAuth(provider, {
|
|
||||||
serverUrl: "https://mcp.example.com",
|
|
||||||
resourceMetadataUrl: new URL("https://mcp.example.com/.well-known/x"),
|
|
||||||
forceReauthorization: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(saveDiscoveryState).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
authorizationServerUrl: "https://as.example.com",
|
|
||||||
resourceMetadataUrl: "https://mcp.example.com/.well-known/x",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(state).toHaveBeenCalled();
|
|
||||||
expect(startAuthorization).toHaveBeenCalledWith(
|
|
||||||
"https://as.example.com",
|
|
||||||
expect.objectContaining({
|
|
||||||
state: "state-123",
|
|
||||||
resource: new URL("https://mcp.example.com/r"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives scope from resourceMetadata.scopes_supported when no scope given", async () => {
|
|
||||||
stubForcePath({
|
|
||||||
resourceMetadata: { scopes_supported: ["mcp", "weather:read"] },
|
|
||||||
});
|
|
||||||
const provider = makeProvider();
|
|
||||||
|
|
||||||
await mcpAuth(provider, {
|
|
||||||
serverUrl: "https://mcp.example.com",
|
|
||||||
forceReauthorization: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(startAuthorization).toHaveBeenCalledWith(
|
|
||||||
"https://as.example.com",
|
|
||||||
expect.objectContaining({ scope: "mcp weather:read" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to clientMetadata.scope when nothing else provides scope", async () => {
|
|
||||||
stubForcePath();
|
|
||||||
const provider = makeProvider({
|
|
||||||
clientMetadata: {
|
|
||||||
...makeProvider().clientMetadata,
|
|
||||||
scope: "fallback:scope",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await mcpAuth(provider, {
|
|
||||||
serverUrl: "https://mcp.example.com",
|
|
||||||
forceReauthorization: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(startAuthorization).toHaveBeenCalledWith(
|
|
||||||
"https://as.example.com",
|
|
||||||
expect.objectContaining({ scope: "fallback:scope" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dynamically registers a client when no client information exists", async () => {
|
|
||||||
stubForcePath();
|
|
||||||
registerClient.mockResolvedValue({ client_id: "registered" });
|
|
||||||
const saveClientInformation = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const provider = makeProvider({
|
|
||||||
clientInformation: vi.fn().mockResolvedValue(undefined),
|
|
||||||
saveClientInformation,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await mcpAuth(provider, {
|
|
||||||
serverUrl: "https://mcp.example.com",
|
|
||||||
scope: "mcp",
|
|
||||||
forceReauthorization: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe("REDIRECT");
|
|
||||||
expect(registerClient).toHaveBeenCalledWith(
|
|
||||||
"https://as.example.com",
|
|
||||||
expect.objectContaining({ scope: "mcp" }),
|
|
||||||
);
|
|
||||||
expect(saveClientInformation).toHaveBeenCalledWith({
|
|
||||||
client_id: "registered",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a URL-based client id when the AS supports it", async () => {
|
|
||||||
stubForcePath({
|
|
||||||
authorizationServerMetadata: {
|
|
||||||
issuer: "https://as.example.com",
|
|
||||||
client_id_metadata_document_supported: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
isHttpsUrl.mockReturnValue(true);
|
|
||||||
const saveClientInformation = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const provider = makeProvider({
|
|
||||||
clientInformation: vi.fn().mockResolvedValue(undefined),
|
|
||||||
clientMetadataUrl: "https://app.example.com/client.json",
|
|
||||||
saveClientInformation,
|
|
||||||
});
|
|
||||||
|
|
||||||
await mcpAuth(provider, {
|
|
||||||
serverUrl: "https://mcp.example.com",
|
|
||||||
forceReauthorization: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(registerClient).not.toHaveBeenCalled();
|
|
||||||
expect(saveClientInformation).toHaveBeenCalledWith({
|
|
||||||
client_id: "https://app.example.com/client.json",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a non-HTTPS clientMetadataUrl", async () => {
|
|
||||||
stubForcePath();
|
|
||||||
isHttpsUrl.mockReturnValue(false);
|
|
||||||
const provider = makeProvider({
|
|
||||||
clientInformation: vi.fn().mockResolvedValue(undefined),
|
|
||||||
clientMetadataUrl: "http://insecure.example.com/client.json",
|
|
||||||
saveClientInformation: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
mcpAuth(provider, {
|
mcpAuth(provider, { serverUrl: "https://mcp.example.com" }),
|
||||||
serverUrl: "https://mcp.example.com",
|
).rejects.toThrow("discovery failed");
|
||||||
forceReauthorization: true,
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(InvalidClientMetadataError);
|
|
||||||
expect(registerClient).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requires saveClientInformation for dynamic registration", async () => {
|
|
||||||
stubForcePath();
|
|
||||||
const provider = makeProvider({
|
|
||||||
clientInformation: vi.fn().mockResolvedValue(undefined),
|
|
||||||
saveClientInformation: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
mcpAuth(provider, {
|
|
||||||
serverUrl: "https://mcp.example.com",
|
|
||||||
forceReauthorization: true,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(
|
|
||||||
"OAuth client information must be saveable for dynamic registration",
|
|
||||||
);
|
|
||||||
expect(registerClient).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requires a redirectUrl for the authorization_code flow", async () => {
|
|
||||||
stubForcePath();
|
|
||||||
const provider = makeProvider({ redirectUrl: undefined });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
mcpAuth(provider, {
|
|
||||||
serverUrl: "https://mcp.example.com",
|
|
||||||
forceReauthorization: true,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("redirectUrl is required for authorization_code flow");
|
|
||||||
expect(startAuthorization).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,13 @@ describe("OAuthNavigation", () => {
|
||||||
saveTokens: vi.fn(async () => undefined),
|
saveTokens: vi.fn(async () => undefined),
|
||||||
saveCodeVerifier: vi.fn(async () => undefined),
|
saveCodeVerifier: vi.fn(async () => undefined),
|
||||||
getCodeVerifier: vi.fn().mockResolvedValue(undefined),
|
getCodeVerifier: vi.fn().mockResolvedValue(undefined),
|
||||||
clear: vi.fn(),
|
clear: vi.fn(async () => undefined),
|
||||||
|
clearTokens: vi.fn(async () => undefined),
|
||||||
|
clearClientInformation: vi.fn(async () => undefined),
|
||||||
|
clearCodeVerifier: vi.fn(async () => undefined),
|
||||||
|
clearDiscoveryState: vi.fn(async () => undefined),
|
||||||
|
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||||
|
saveDiscoveryState: vi.fn(async () => undefined),
|
||||||
getServerMetadata: vi.fn().mockResolvedValue(null),
|
getServerMetadata: vi.fn().mockResolvedValue(null),
|
||||||
saveServerMetadata: vi.fn(async () => undefined),
|
saveServerMetadata: vi.fn(async () => undefined),
|
||||||
} as unknown as OAuthStorage;
|
} as unknown as OAuthStorage;
|
||||||
|
|
@ -362,17 +368,21 @@ describe("OAuthNavigation", () => {
|
||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(storage.saveTokens).toHaveBeenCalledWith(SERVER, {
|
expect(storage.saveTokens).toHaveBeenCalledWith(
|
||||||
access_token: "t",
|
SERVER,
|
||||||
token_type: "Bearer",
|
{
|
||||||
});
|
access_token: "t",
|
||||||
|
token_type: "Bearer",
|
||||||
|
},
|
||||||
|
{ issuer: undefined },
|
||||||
|
);
|
||||||
expect(storage.saveScope).toHaveBeenCalledWith(SERVER, "openid");
|
expect(storage.saveScope).toHaveBeenCalledWith(SERVER, "openid");
|
||||||
expect(storage.saveClientInformation).toHaveBeenCalledWith(
|
expect(storage.saveClientInformation).toHaveBeenCalledWith(
|
||||||
SERVER,
|
SERVER,
|
||||||
{
|
{
|
||||||
client_id: "c",
|
client_id: "c",
|
||||||
},
|
},
|
||||||
{ registrationKind: "dcr" },
|
{ registrationKind: "dcr", issuer: undefined },
|
||||||
);
|
);
|
||||||
expect(storage.savePreregisteredClientInformation).toHaveBeenCalledWith(
|
expect(storage.savePreregisteredClientInformation).toHaveBeenCalledWith(
|
||||||
SERVER,
|
SERVER,
|
||||||
|
|
@ -386,5 +396,87 @@ describe("OAuthNavigation", () => {
|
||||||
expect(typeof state).toBe("string");
|
expect(typeof state).toBe("string");
|
||||||
expect(state.length).toBeGreaterThan(0);
|
expect(state.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("declares application_type 'native' in DCR client metadata (SEP-837)", () => {
|
||||||
|
const provider = makeProvider(makeStorage());
|
||||||
|
expect(provider.clientMetadata.application_type).toBe("native");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SEP-2352 issuer threading", () => {
|
||||||
|
it("forwards ctx.issuer to storage on clientInformation/tokens reads", async () => {
|
||||||
|
const storage = makeStorage();
|
||||||
|
const provider = makeProvider(storage);
|
||||||
|
const issuer = "https://as.example.com";
|
||||||
|
|
||||||
|
await provider.clientInformation({ issuer });
|
||||||
|
await provider.tokens({ issuer });
|
||||||
|
|
||||||
|
// Preregistered lookup (issuer-independent) then the per-issuer dynamic slot.
|
||||||
|
expect(storage.getClientInformation).toHaveBeenCalledWith(SERVER, true);
|
||||||
|
expect(storage.getClientInformation).toHaveBeenCalledWith(
|
||||||
|
SERVER,
|
||||||
|
false,
|
||||||
|
issuer,
|
||||||
|
);
|
||||||
|
expect(storage.getTokens).toHaveBeenCalledWith(SERVER, issuer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keys saves by ctx.issuer and defaults registration kind to dcr", async () => {
|
||||||
|
const storage = makeStorage();
|
||||||
|
const provider = makeProvider(storage);
|
||||||
|
const issuer = "https://as.example.com";
|
||||||
|
|
||||||
|
await provider.saveClientInformation({ client_id: "c" }, { issuer });
|
||||||
|
await provider.saveTokens(
|
||||||
|
{ access_token: "t", token_type: "Bearer" },
|
||||||
|
{ issuer },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(storage.saveClientInformation).toHaveBeenCalledWith(
|
||||||
|
SERVER,
|
||||||
|
{ client_id: "c" },
|
||||||
|
{ registrationKind: "dcr", issuer },
|
||||||
|
);
|
||||||
|
expect(storage.saveTokens).toHaveBeenCalledWith(
|
||||||
|
SERVER,
|
||||||
|
{ access_token: "t", token_type: "Bearer" },
|
||||||
|
{ issuer },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips discovery state to storage", async () => {
|
||||||
|
const storage = makeStorage();
|
||||||
|
const provider = makeProvider(storage);
|
||||||
|
const discoveryState = {
|
||||||
|
authorizationServerUrl: "https://as.example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
await provider.saveDiscoveryState(discoveryState);
|
||||||
|
await provider.discoveryState();
|
||||||
|
|
||||||
|
expect(storage.saveDiscoveryState).toHaveBeenCalledWith(
|
||||||
|
SERVER,
|
||||||
|
discoveryState,
|
||||||
|
);
|
||||||
|
expect(storage.getDiscoveryState).toHaveBeenCalledWith(SERVER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps invalidateCredentials scopes to the matching storage clear", async () => {
|
||||||
|
const storage = makeStorage();
|
||||||
|
const provider = makeProvider(storage);
|
||||||
|
|
||||||
|
await provider.invalidateCredentials("all");
|
||||||
|
await provider.invalidateCredentials("client");
|
||||||
|
await provider.invalidateCredentials("tokens");
|
||||||
|
await provider.invalidateCredentials("verifier");
|
||||||
|
await provider.invalidateCredentials("discovery");
|
||||||
|
|
||||||
|
expect(storage.clear).toHaveBeenCalledWith(SERVER);
|
||||||
|
expect(storage.clearClientInformation).toHaveBeenCalledWith(SERVER);
|
||||||
|
expect(storage.clearTokens).toHaveBeenCalledWith(SERVER);
|
||||||
|
expect(storage.clearCodeVerifier).toHaveBeenCalledWith(SERVER);
|
||||||
|
expect(storage.clearDiscoveryState).toHaveBeenCalledWith(SERVER);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -429,4 +429,216 @@ describe("BrowserOAuthStorage", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SEP-2352 per-issuer keying", () => {
|
||||||
|
const issuerA = "https://as-a.example.com";
|
||||||
|
const issuerB = "https://as-b.example.com";
|
||||||
|
|
||||||
|
it("keeps separate client registrations per issuer and re-attaches the issuer stamp on read", async () => {
|
||||||
|
await storage.saveClientInformation(
|
||||||
|
testServerUrl,
|
||||||
|
{ client_id: "client-a" },
|
||||||
|
{ registrationKind: "dcr", issuer: issuerA },
|
||||||
|
);
|
||||||
|
await storage.saveClientInformation(
|
||||||
|
testServerUrl,
|
||||||
|
{ client_id: "client-b" },
|
||||||
|
{ registrationKind: "cimd", issuer: issuerB },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Each issuer resolves its own credentials, stamped with its issuer
|
||||||
|
// (SEP-2352) — the stamp is what lets the SDK reject cross-AS reuse.
|
||||||
|
expect(
|
||||||
|
await storage.getClientInformation(testServerUrl, false, issuerA),
|
||||||
|
).toEqual({ client_id: "client-a", issuer: issuerA });
|
||||||
|
expect(
|
||||||
|
await storage.getClientInformation(testServerUrl, false, issuerB),
|
||||||
|
).toEqual({ client_id: "client-b", issuer: issuerB });
|
||||||
|
expect(
|
||||||
|
await storage.getClientRegistrationKind(testServerUrl, issuerA),
|
||||||
|
).toBe("dcr");
|
||||||
|
expect(
|
||||||
|
await storage.getClientRegistrationKind(testServerUrl, issuerB),
|
||||||
|
).toBe("cimd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a ctx-less token read from the most-recently-saved (active) issuer", async () => {
|
||||||
|
await storage.saveTokens(
|
||||||
|
testServerUrl,
|
||||||
|
{ access_token: "tok-a", token_type: "Bearer" },
|
||||||
|
{ issuer: issuerA },
|
||||||
|
);
|
||||||
|
await storage.saveTokens(
|
||||||
|
testServerUrl,
|
||||||
|
{ access_token: "tok-b", token_type: "Bearer" },
|
||||||
|
{ issuer: issuerB },
|
||||||
|
);
|
||||||
|
|
||||||
|
// No issuer → active issuer (last saved = B); the per-request bearer read.
|
||||||
|
expect(await storage.getTokens(testServerUrl)).toEqual({
|
||||||
|
access_token: "tok-b",
|
||||||
|
token_type: "Bearer",
|
||||||
|
issuer: issuerB,
|
||||||
|
});
|
||||||
|
// Explicit issuer selects that AS's tokens.
|
||||||
|
expect(await storage.getTokens(testServerUrl, issuerA)).toEqual({
|
||||||
|
access_token: "tok-a",
|
||||||
|
token_type: "Bearer",
|
||||||
|
issuer: issuerA,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promotes the legacy unkeyed slot: an unstamped token read falls back, then a stamped save supersedes it", async () => {
|
||||||
|
// Legacy pre-1625 blob: unstamped tokens at the top level.
|
||||||
|
await storage.saveTokens(testServerUrl, {
|
||||||
|
access_token: "legacy",
|
||||||
|
token_type: "Bearer",
|
||||||
|
});
|
||||||
|
// Read for a specific issuer falls back to the unkeyed slot (unstamped).
|
||||||
|
expect(await storage.getTokens(testServerUrl, issuerA)).toEqual({
|
||||||
|
access_token: "legacy",
|
||||||
|
token_type: "Bearer",
|
||||||
|
});
|
||||||
|
|
||||||
|
// First stamped save promotes into byIssuer and clears the fallback.
|
||||||
|
await storage.saveTokens(
|
||||||
|
testServerUrl,
|
||||||
|
{ access_token: "fresh", token_type: "Bearer" },
|
||||||
|
{ issuer: issuerA },
|
||||||
|
);
|
||||||
|
expect(await storage.getTokens(testServerUrl, issuerA)).toEqual({
|
||||||
|
access_token: "fresh",
|
||||||
|
token_type: "Bearer",
|
||||||
|
issuer: issuerA,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a single issuer's tokens, leaving other issuers intact", async () => {
|
||||||
|
await storage.saveTokens(
|
||||||
|
testServerUrl,
|
||||||
|
{ access_token: "tok-a", token_type: "Bearer" },
|
||||||
|
{ issuer: issuerA },
|
||||||
|
);
|
||||||
|
await storage.saveTokens(
|
||||||
|
testServerUrl,
|
||||||
|
{ access_token: "tok-b", token_type: "Bearer" },
|
||||||
|
{ issuer: issuerB },
|
||||||
|
);
|
||||||
|
|
||||||
|
await storage.clearTokens(testServerUrl, issuerA);
|
||||||
|
|
||||||
|
expect(await storage.getTokens(testServerUrl, issuerA)).toBeUndefined();
|
||||||
|
expect(await storage.getTokens(testServerUrl, issuerB)).toMatchObject({
|
||||||
|
access_token: "tok-b",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not stamp a legacy fallback credential when the issuer slot lacks it", async () => {
|
||||||
|
// Legacy unkeyed client info survives, but a byIssuer slot exists for
|
||||||
|
// issuerA holding only tokens (no clientInformation). Reading client info
|
||||||
|
// for issuerA must fall back to the legacy value WITHOUT stamping it with
|
||||||
|
// issuerA — the legacy credential may have been minted by a different AS.
|
||||||
|
await storage.saveClientInformation(
|
||||||
|
testServerUrl,
|
||||||
|
{ client_id: "legacy-client" },
|
||||||
|
{ registrationKind: "dcr" },
|
||||||
|
);
|
||||||
|
await storage.saveTokens(
|
||||||
|
testServerUrl,
|
||||||
|
{ access_token: "tok-a", token_type: "Bearer" },
|
||||||
|
{ issuer: issuerA },
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientInfo = await storage.getClientInformation(
|
||||||
|
testServerUrl,
|
||||||
|
false,
|
||||||
|
issuerA,
|
||||||
|
);
|
||||||
|
// No issuer stamp — so the SDK's discardIfIssuerMismatch stays engaged.
|
||||||
|
expect(clientInfo).toEqual({ client_id: "legacy-client" });
|
||||||
|
|
||||||
|
// Symmetric case: legacy tokens survive, slot has only clientInformation.
|
||||||
|
await storage.clear(testServerUrl);
|
||||||
|
await storage.saveTokens(testServerUrl, {
|
||||||
|
access_token: "legacy-tok",
|
||||||
|
token_type: "Bearer",
|
||||||
|
});
|
||||||
|
await storage.saveClientInformation(
|
||||||
|
testServerUrl,
|
||||||
|
{ client_id: "client-a" },
|
||||||
|
{ registrationKind: "dcr", issuer: issuerA },
|
||||||
|
);
|
||||||
|
const tokens = await storage.getTokens(testServerUrl, issuerA);
|
||||||
|
expect(tokens).toEqual({
|
||||||
|
access_token: "legacy-tok",
|
||||||
|
token_type: "Bearer",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not promote a cleared issuer to the active (ctx-less) slot", async () => {
|
||||||
|
await storage.saveTokens(
|
||||||
|
testServerUrl,
|
||||||
|
{ access_token: "tok-a", token_type: "Bearer" },
|
||||||
|
{ issuer: issuerA },
|
||||||
|
);
|
||||||
|
// Clearing a *different* issuer must not make it the active issuer.
|
||||||
|
await storage.clearTokens(testServerUrl, issuerB);
|
||||||
|
|
||||||
|
// The ctx-less (per-request bearer) read still resolves issuerA's token.
|
||||||
|
expect(await storage.getTokens(testServerUrl)).toMatchObject({
|
||||||
|
access_token: "tok-a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears every issuer's tokens/registration when no issuer is given", async () => {
|
||||||
|
await storage.saveTokens(
|
||||||
|
testServerUrl,
|
||||||
|
{ access_token: "tok-a", token_type: "Bearer" },
|
||||||
|
{ issuer: issuerA },
|
||||||
|
);
|
||||||
|
await storage.saveClientInformation(
|
||||||
|
testServerUrl,
|
||||||
|
{ client_id: "client-b" },
|
||||||
|
{ registrationKind: "dcr", issuer: issuerB },
|
||||||
|
);
|
||||||
|
|
||||||
|
await storage.clearTokens(testServerUrl);
|
||||||
|
await storage.clearClientInformation(testServerUrl);
|
||||||
|
|
||||||
|
expect(await storage.getTokens(testServerUrl, issuerA)).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
await storage.getClientInformation(testServerUrl, false, issuerB),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tags EMA resource tokens even on the issuer-keyed save path", async () => {
|
||||||
|
await storage.saveTokens(
|
||||||
|
testServerUrl,
|
||||||
|
{ access_token: "ema", token_type: "Bearer" },
|
||||||
|
{ issuer: issuerA, enterpriseManaged: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
await storage.clearEnterpriseManagedResourceServers();
|
||||||
|
|
||||||
|
expect(await storage.getTokens(testServerUrl, issuerA)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("discovery state (SEP-2352)", () => {
|
||||||
|
it("saves, reads, and clears the discovery state", async () => {
|
||||||
|
expect(await storage.getDiscoveryState(testServerUrl)).toBeUndefined();
|
||||||
|
|
||||||
|
const discoveryState = {
|
||||||
|
authorizationServerUrl: "https://as.example.com",
|
||||||
|
resourceMetadataUrl: "https://mcp.example.com/.well-known/x",
|
||||||
|
};
|
||||||
|
await storage.saveDiscoveryState(testServerUrl, discoveryState);
|
||||||
|
expect(await storage.getDiscoveryState(testServerUrl)).toEqual(
|
||||||
|
discoveryState,
|
||||||
|
);
|
||||||
|
|
||||||
|
await storage.clearDiscoveryState(testServerUrl);
|
||||||
|
expect(await storage.getDiscoveryState(testServerUrl)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ function createMockParams(
|
||||||
saveIdpSession: vi.fn().mockResolvedValue(undefined),
|
saveIdpSession: vi.fn().mockResolvedValue(undefined),
|
||||||
clearIdpSession: vi.fn(),
|
clearIdpSession: vi.fn(),
|
||||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||||
|
getDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||||
|
saveDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||||
|
clearDiscoveryState: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
const redirectUrlProvider = {
|
const redirectUrlProvider = {
|
||||||
|
|
@ -1231,6 +1234,9 @@ describe("OAuthManager", () => {
|
||||||
expect(
|
expect(
|
||||||
params.initialConfig.navigation!.navigateToAuthorization,
|
params.initialConfig.navigation!.navigateToAuthorization,
|
||||||
).not.toHaveBeenCalled();
|
).not.toHaveBeenCalled();
|
||||||
|
// SEP-2350: a step-up redirect is recorded as `scope_step_up`, distinct
|
||||||
|
// from a first-time `authorization_code` login.
|
||||||
|
expect(manager.getOAuthFlowStep()).toBe("scope_step_up");
|
||||||
captureSpy.mockRestore();
|
captureSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -863,6 +863,43 @@ describe("enterpriseManaged oauth settings", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("oauthOnInsufficientScope (SEP-2350)", () => {
|
||||||
|
const baseSettings = {
|
||||||
|
headers: [],
|
||||||
|
env: [],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
taskTtl: 60000,
|
||||||
|
maxFetchRequests: 1000,
|
||||||
|
roots: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("lifts oauth.onInsufficientScope to settings", () => {
|
||||||
|
const settings = storedFieldsToInspectorSettings({
|
||||||
|
oauth: { clientId: "cid", onInsufficientScope: "throw" },
|
||||||
|
});
|
||||||
|
expect(settings?.oauthOnInsufficientScope).toBe("throw");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists onInsufficientScope under oauth on disk", () => {
|
||||||
|
const stored = inspectorSettingsToStoredFields({
|
||||||
|
...baseSettings,
|
||||||
|
oauthClientId: "cid",
|
||||||
|
oauthOnInsufficientScope: "throw",
|
||||||
|
});
|
||||||
|
expect(stored.oauth?.onInsufficientScope).toBe("throw");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits onInsufficientScope when unset", () => {
|
||||||
|
const stored = inspectorSettingsToStoredFields({
|
||||||
|
...baseSettings,
|
||||||
|
oauthClientId: "cid",
|
||||||
|
});
|
||||||
|
expect(stored.oauth?.onInsufficientScope).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("envRecordToPairs / envPairsToRecord", () => {
|
describe("envRecordToPairs / envPairsToRecord", () => {
|
||||||
it("envRecordToPairs preserves key insertion order", () => {
|
it("envRecordToPairs preserves key insertion order", () => {
|
||||||
expect(envRecordToPairs({ B: "2", A: "1" })).toEqual([
|
expect(envRecordToPairs({ B: "2", A: "1" })).toEqual([
|
||||||
|
|
|
||||||
|
|
@ -1071,8 +1071,12 @@ describe("InspectorClient OAuth E2E", () => {
|
||||||
|
|
||||||
expect(client.getStatus()).toBe("connected");
|
expect(client.getStatus()).toBe("connected");
|
||||||
|
|
||||||
|
type StoredTokens = { tokens?: { access_token?: string } };
|
||||||
type StateShape = {
|
type StateShape = {
|
||||||
servers?: Record<string, { tokens?: { access_token?: string } }>;
|
servers?: Record<
|
||||||
|
string,
|
||||||
|
StoredTokens & { byIssuer?: Record<string, StoredTokens> }
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
// Persistence is fire-and-forget; await the write rather than polling.
|
// Persistence is fire-and-forget; await the write rather than polling.
|
||||||
await flushStoreFileWrites(customPath);
|
await flushStoreFileWrites(customPath);
|
||||||
|
|
@ -1081,8 +1085,16 @@ describe("InspectorClient OAuth E2E", () => {
|
||||||
) as StateShape;
|
) as StateShape;
|
||||||
const servers = parsed.servers ?? {};
|
const servers = parsed.servers ?? {};
|
||||||
expect(Object.keys(servers).length).toBeGreaterThan(0);
|
expect(Object.keys(servers).length).toBeGreaterThan(0);
|
||||||
|
// SEP-2352: tokens persist under `byIssuer[issuer].tokens`; accept the
|
||||||
|
// legacy top-level slot too.
|
||||||
expect(
|
expect(
|
||||||
Object.values(servers).some((s) => !!s?.tokens?.access_token),
|
Object.values(servers).some(
|
||||||
|
(s) =>
|
||||||
|
!!s?.tokens?.access_token ||
|
||||||
|
Object.values(s?.byIssuer ?? {}).some(
|
||||||
|
(slot) => !!slot?.tokens?.access_token,
|
||||||
|
),
|
||||||
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -351,12 +351,24 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => {
|
||||||
"oauth",
|
"oauth",
|
||||||
remoteAuthToken!,
|
remoteAuthToken!,
|
||||||
(body) => {
|
(body) => {
|
||||||
|
// SEP-2352: tokens persist under `byIssuer[issuer].tokens`, keyed by
|
||||||
|
// authorization-server issuer. Accept the legacy top-level slot too.
|
||||||
|
type StoredTokens = { tokens?: { access_token?: string } };
|
||||||
const b = body as {
|
const b = body as {
|
||||||
servers?: Record<string, { tokens?: { access_token?: string } }>;
|
servers?: Record<
|
||||||
|
string,
|
||||||
|
StoredTokens & { byIssuer?: Record<string, StoredTokens> }
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
return !!(
|
return !!(
|
||||||
b?.servers &&
|
b?.servers &&
|
||||||
Object.values(b.servers).some((s) => s?.tokens?.access_token)
|
Object.values(b.servers).some(
|
||||||
|
(s) =>
|
||||||
|
s?.tokens?.access_token ||
|
||||||
|
Object.values(s?.byIssuer ?? {}).some(
|
||||||
|
(slot) => slot?.tokens?.access_token,
|
||||||
|
),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,38 @@ describe("Transport", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards settings.oauthOnInsufficientScope to the streamable-http transport (SEP-2350)", () => {
|
||||||
|
const config: MCPServerConfig = {
|
||||||
|
type: "streamable-http",
|
||||||
|
url: "https://mcp.example.com/mcp",
|
||||||
|
};
|
||||||
|
const baseSettings: InspectorServerSettings = {
|
||||||
|
headers: [],
|
||||||
|
env: [],
|
||||||
|
metadata: [],
|
||||||
|
connectionTimeout: 0,
|
||||||
|
requestTimeout: 0,
|
||||||
|
taskTtl: 0,
|
||||||
|
maxFetchRequests: 1000,
|
||||||
|
roots: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const asThrow = createTransportNode(config, {
|
||||||
|
settings: { ...baseSettings, oauthOnInsufficientScope: "throw" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(asThrow.transport as unknown as { _onInsufficientScope?: string })
|
||||||
|
._onInsufficientScope,
|
||||||
|
).toBe("throw");
|
||||||
|
|
||||||
|
// Unset → the SDK's default policy.
|
||||||
|
const asDefault = createTransportNode(config, { settings: baseSettings });
|
||||||
|
expect(
|
||||||
|
(asDefault.transport as unknown as { _onInsufficientScope?: string })
|
||||||
|
._onInsufficientScope,
|
||||||
|
).toBe("reauthorize");
|
||||||
|
});
|
||||||
|
|
||||||
it("applies settings.headers to the outgoing SSE request", async () => {
|
it("applies settings.headers to the outgoing SSE request", async () => {
|
||||||
const server = createTestServerHttp({
|
const server = createTestServerHttp({
|
||||||
serverInfo: createTestServerInfo(),
|
serverInfo: createTestServerInfo(),
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue