feat: surface and decode the OAuth id_token in Connection Info (#2019)
When an authorization server returns an `id_token` alongside the access token, the Inspector already persisted it and never showed it. Wire it through to Connection Info as a viewer. - Generalize `OAuthAccessTokenField` into `OAuthTokenField` (label + token), so the ID Token row reuses the existing copy control and "Decode JWT" toggle rather than duplicating them. Access-token behavior is unchanged. - `oauthDetailsFromConnectionState` now surfaces `tokens.id_token` as `idToken`, omitted entirely when absent so no empty row renders. - Docs: update the two `specification/` references to the renamed field. This is deliberately a viewer, not a flow: no OIDC login mode, no signature verification, and the `id_token` is never treated as a credential. The EMA path is untouched. `id_token` is already in `maskSecrets`' `JSON_SENSITIVE_KEYS`, so it stays masked in the Network tab — no gap. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
ebeaa142df
commit
07cafeedbc
9 changed files with 211 additions and 36 deletions
|
|
@ -3,7 +3,7 @@ import type {
|
||||||
InitializeResult,
|
InitializeResult,
|
||||||
} from "@modelcontextprotocol/client";
|
} from "@modelcontextprotocol/client";
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { expect, within } from "storybook/test";
|
import { expect, userEvent, within } from "storybook/test";
|
||||||
import {
|
import {
|
||||||
ConnectionInfoContent,
|
ConnectionInfoContent,
|
||||||
SERVER_INFO_NOT_REPORTED_LABEL,
|
SERVER_INFO_NOT_REPORTED_LABEL,
|
||||||
|
|
@ -161,3 +161,35 @@ export const WithOAuth: Story = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// An authorization server that also returned an `id_token` (#2019). Shown for
|
||||||
|
// inspection only — decoding it is display-only, and it is never used as a
|
||||||
|
// credential.
|
||||||
|
export const WithOAuthIdToken: Story = {
|
||||||
|
args: {
|
||||||
|
initializeResult: {
|
||||||
|
protocolVersion: "2025-03-26",
|
||||||
|
serverInfo: { name: "OIDC-fronted Server", version: "3.0.0" },
|
||||||
|
capabilities: { tools: { listChanged: true } },
|
||||||
|
},
|
||||||
|
clientCapabilities: { roots: { listChanged: true } },
|
||||||
|
transport: "streamable-http",
|
||||||
|
oauth: {
|
||||||
|
protocol: "standard",
|
||||||
|
authorized: true,
|
||||||
|
authUrl: "https://auth.example.com/oauth2/authorize",
|
||||||
|
scopes: ["openid", "email", "read"],
|
||||||
|
accessToken: "opaque-access-token",
|
||||||
|
// header {"alg":"none"} / payload {"sub":"user-42","email":"a@b.test"}
|
||||||
|
idToken:
|
||||||
|
"eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTQyIiwiZW1haWwiOiJhQGIudGVzdCJ9.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
expect(canvas.getByText("ID Token")).toBeInTheDocument();
|
||||||
|
// Only the ID token is a JWT here, so exactly one decode toggle is offered.
|
||||||
|
await userEvent.click(canvas.getByRole("button", { name: "Decode JWT" }));
|
||||||
|
expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -473,6 +473,60 @@ describe("ConnectionInfoContent", () => {
|
||||||
expect(screen.queryByText("Auth URL")).not.toBeInTheDocument();
|
expect(screen.queryByText("Auth URL")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Scopes")).not.toBeInTheDocument();
|
expect(screen.queryByText("Scopes")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("ID Token")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the ID Token row when the token set carries one", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="streamable-http"
|
||||||
|
oauth={{
|
||||||
|
protocol: "standard",
|
||||||
|
authorized: true,
|
||||||
|
accessToken: "token-123",
|
||||||
|
idToken: "id-token-456",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("ID Token")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("id-token-456")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the ID Token row on its own when there is no access token", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="streamable-http"
|
||||||
|
oauth={{
|
||||||
|
protocol: "standard",
|
||||||
|
authorized: true,
|
||||||
|
idToken: "id-token-456",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("ID Token")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the ID Token row when only an access token is present", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="streamable-http"
|
||||||
|
oauth={{
|
||||||
|
protocol: "standard",
|
||||||
|
authorized: true,
|
||||||
|
accessToken: "token-123",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("ID Token")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render OAuth section when oauth prop is omitted", () => {
|
it("does not render OAuth section when oauth prop is omitted", () => {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import {
|
||||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||||
import { EraBadge } from "../../elements/EraBadge/EraBadge";
|
import { EraBadge } from "../../elements/EraBadge/EraBadge";
|
||||||
import { isModernEra } from "../../elements/EraBadge/eraUtils";
|
import { isModernEra } from "../../elements/EraBadge/eraUtils";
|
||||||
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
|
import { OAuthTokenField } from "./OAuthTokenField";
|
||||||
|
|
||||||
export interface OAuthDetails {
|
export interface OAuthDetails {
|
||||||
protocol: "standard" | "ema";
|
protocol: "standard" | "ema";
|
||||||
|
|
@ -34,6 +34,13 @@ export interface OAuthDetails {
|
||||||
authUrl?: string;
|
authUrl?: string;
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
|
/**
|
||||||
|
* OIDC `id_token` from the stored token set, when the authorization server
|
||||||
|
* returned one. Shown for inspection only — the MCP authorization spec is
|
||||||
|
* plain OAuth 2.1 and the Inspector never treats this as a credential
|
||||||
|
* (#2019). Absent when the token set carries none, so no empty row renders.
|
||||||
|
*/
|
||||||
|
idToken?: string;
|
||||||
/** EMA only — install-level IdP session for legs 1–2. */
|
/** EMA only — install-level IdP session for legs 1–2. */
|
||||||
idpSession?: "none" | "logged_in" | "expired";
|
idpSession?: "none" | "logged_in" | "expired";
|
||||||
}
|
}
|
||||||
|
|
@ -370,20 +377,27 @@ export function ConnectionInfoContent({
|
||||||
<ValueText>{formatScopes(oauth.scopes)}</ValueText>
|
<ValueText>{formatScopes(oauth.scopes)}</ValueText>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
{oauth.accessToken ? (
|
{oauth.accessToken && (
|
||||||
<OAuthAccessTokenField
|
<OAuthTokenField
|
||||||
accessToken={oauth.accessToken}
|
label="Access Token"
|
||||||
|
token={oauth.accessToken}
|
||||||
onClear={onClearOAuth}
|
onClear={onClearOAuth}
|
||||||
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
onClearOAuth && (
|
{/* Viewer only — an `id_token` the AS happened to return, decoded
|
||||||
<Flex justify="flex-end">
|
on request. It carries no clear action: clearing OAuth state is
|
||||||
<ClearOAuthButton onClick={onClearOAuth}>
|
one action for the whole token set, owned by the access-token
|
||||||
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
row (or the standalone button below when there is none). */}
|
||||||
</ClearOAuthButton>
|
{oauth.idToken && (
|
||||||
</Flex>
|
<OAuthTokenField label="ID Token" token={oauth.idToken} />
|
||||||
)
|
)}
|
||||||
|
{!oauth.accessToken && onClearOAuth && (
|
||||||
|
<Flex justify="flex-end">
|
||||||
|
<ClearOAuthButton onClick={onClearOAuth}>
|
||||||
|
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
||||||
|
</ClearOAuthButton>
|
||||||
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
|
import { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL } from "./ConnectionInfoContent";
|
||||||
|
import { OAuthTokenField } from "./OAuthTokenField";
|
||||||
|
|
||||||
|
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
|
||||||
|
|
||||||
|
describe("OAuthTokenField", () => {
|
||||||
|
it("renders token with copy control beside the content", () => {
|
||||||
|
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||||
|
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces raw token with decoded JSON and restores on toggle", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
|
||||||
|
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Show token" }));
|
||||||
|
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits decode toggle for opaque tokens", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<OAuthTokenField
|
||||||
|
label="Access Token"
|
||||||
|
token="opaque-access-token-value"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Decode JWT" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies decoded JSON while decode view is shown", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: { writeText },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Copy" }));
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('"sub": "user"'),
|
||||||
|
);
|
||||||
|
expect(writeText).not.toHaveBeenCalledWith(jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies the raw token while token view is shown", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: { writeText },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Copy" }));
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledWith(jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders clear action on the access token header row", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClear = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<OAuthTokenField
|
||||||
|
label="Access Token"
|
||||||
|
token="opaque-access-token-value"
|
||||||
|
onClear={onClear}
|
||||||
|
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onClear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the row from the label prop and decodes an ID token", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
// header {"alg":"none"} / payload {"sub":"user"} — same shape an AS returns
|
||||||
|
// for an `id_token`; the decode path is identical to the access token's.
|
||||||
|
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("ID Token")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
|
||||||
|
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the clear action when no handler is given", () => {
|
||||||
|
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", {
|
||||||
|
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
|
||||||
|
}),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { Fragment, useMemo, useState } from "react";
|
||||||
|
import { Button, Code, Flex, Stack, Text } from "@mantine/core";
|
||||||
|
import { decodeJwtPayload, isJwtFormat } from "@inspector/core/auth/ema/jwt.js";
|
||||||
|
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One labelled OAuth token row: the raw value, a copy control, and — when the
|
||||||
|
* value has the three-segment JWT shape — an in-place "Decode JWT" toggle.
|
||||||
|
*
|
||||||
|
* Shared by the Access Token and ID Token rows (#2019). The decode is
|
||||||
|
* display-only (`decodeJwtPayload` does no signature verification), so this is
|
||||||
|
* a viewer: neither token is treated as a credential here.
|
||||||
|
*/
|
||||||
|
export interface OAuthTokenFieldProps {
|
||||||
|
/** Row caption, e.g. "Access Token" / "ID Token". */
|
||||||
|
label: string;
|
||||||
|
token: string;
|
||||||
|
onClear?: () => void;
|
||||||
|
clearLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CaptionRow = Flex.withProps({
|
||||||
|
justify: "space-between",
|
||||||
|
align: "center",
|
||||||
|
gap: "sm",
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const Caption = Text.withProps({ size: "sm" });
|
||||||
|
|
||||||
|
const Toolbar = Flex.withProps({
|
||||||
|
gap: 4,
|
||||||
|
align: "center",
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ToolbarButton = Button.withProps({
|
||||||
|
variant: "subtle",
|
||||||
|
size: "compact-xs",
|
||||||
|
});
|
||||||
|
|
||||||
|
const TokenRow = Flex.withProps({
|
||||||
|
align: "flex-start",
|
||||||
|
gap: 4,
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const TokenColumn = Flex.withProps({
|
||||||
|
flex: 1,
|
||||||
|
miw: 0,
|
||||||
|
direction: "column",
|
||||||
|
});
|
||||||
|
|
||||||
|
const TokenCode = Code.withProps({
|
||||||
|
block: true,
|
||||||
|
py: "xs",
|
||||||
|
ps: "xs",
|
||||||
|
pe: 0,
|
||||||
|
variant: "wrapping",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Wrap JWT at segment boundaries; break long segments without orphaning `.`. */
|
||||||
|
function JwtTokenText({ token }: { token: string }) {
|
||||||
|
const parts = token.split(".");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{index > 0 && "."}
|
||||||
|
{part}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OAuthTokenField({
|
||||||
|
label,
|
||||||
|
token,
|
||||||
|
onClear,
|
||||||
|
clearLabel = "Clear",
|
||||||
|
}: OAuthTokenFieldProps) {
|
||||||
|
const [showDecoded, setShowDecoded] = useState(false);
|
||||||
|
const isJwt = isJwtFormat(token);
|
||||||
|
const jwtDecoded = useMemo(
|
||||||
|
() => (isJwt ? decodeJwtPayload(token) : undefined),
|
||||||
|
[token, isJwt],
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodedText = useMemo(() => {
|
||||||
|
if (!jwtDecoded) return undefined;
|
||||||
|
return JSON.stringify(
|
||||||
|
{ header: jwtDecoded.header, payload: jwtDecoded.payload },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}, [jwtDecoded]);
|
||||||
|
|
||||||
|
const copyValue = showDecoded && decodedText ? decodedText : token;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<CaptionRow>
|
||||||
|
<Caption>{label}</Caption>
|
||||||
|
<Toolbar>
|
||||||
|
{jwtDecoded && (
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => setShowDecoded((open) => !open)}
|
||||||
|
aria-pressed={showDecoded}
|
||||||
|
>
|
||||||
|
{showDecoded ? "Show token" : "Decode JWT"}
|
||||||
|
</ToolbarButton>
|
||||||
|
)}
|
||||||
|
{onClear && (
|
||||||
|
<ToolbarButton color="red" onClick={onClear}>
|
||||||
|
{clearLabel}
|
||||||
|
</ToolbarButton>
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
</CaptionRow>
|
||||||
|
<TokenRow>
|
||||||
|
<TokenColumn>
|
||||||
|
<TokenCode>
|
||||||
|
{showDecoded && decodedText ? (
|
||||||
|
decodedText
|
||||||
|
) : isJwt ? (
|
||||||
|
<JwtTokenText token={token} />
|
||||||
|
) : (
|
||||||
|
token
|
||||||
|
)}
|
||||||
|
</TokenCode>
|
||||||
|
</TokenColumn>
|
||||||
|
<CopyButton value={copyValue} flush />
|
||||||
|
</TokenRow>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,39 @@ describe("oauthDetailsFromConnectionState", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("surfaces an id_token when the token set carries one", () => {
|
||||||
|
const state: OAuthConnectionState = {
|
||||||
|
authorized: true,
|
||||||
|
protocol: "standard",
|
||||||
|
serverUrl: "https://mcp.example.com/mcp",
|
||||||
|
tokens: {
|
||||||
|
access_token: "tok",
|
||||||
|
token_type: "Bearer",
|
||||||
|
id_token: "header.payload.signature",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(oauthDetailsFromConnectionState(state)).toEqual({
|
||||||
|
protocol: "standard",
|
||||||
|
authorized: true,
|
||||||
|
accessToken: "tok",
|
||||||
|
idToken: "header.payload.signature",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits idToken when the token set carries none", () => {
|
||||||
|
const state: OAuthConnectionState = {
|
||||||
|
authorized: true,
|
||||||
|
protocol: "standard",
|
||||||
|
serverUrl: "https://mcp.example.com/mcp",
|
||||||
|
tokens: { access_token: "tok", token_type: "Bearer" },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(oauthDetailsFromConnectionState(state)).not.toHaveProperty(
|
||||||
|
"idToken",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("includes EMA idp session when present", () => {
|
it("includes EMA idp session when present", () => {
|
||||||
const state: OAuthConnectionState = {
|
const state: OAuthConnectionState = {
|
||||||
authorized: false,
|
authorized: false,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ export function oauthDetailsFromConnectionState(
|
||||||
...(state.tokens?.access_token && {
|
...(state.tokens?.access_token && {
|
||||||
accessToken: state.tokens.access_token,
|
accessToken: state.tokens.access_token,
|
||||||
}),
|
}),
|
||||||
|
// Surfaced for inspection when the AS returned one; omitted entirely
|
||||||
|
// otherwise so Connection Info renders no empty row (#2019).
|
||||||
|
...(state.tokens?.id_token && {
|
||||||
|
idToken: state.tokens.id_token,
|
||||||
|
}),
|
||||||
...(state.protocol === "ema" &&
|
...(state.protocol === "ema" &&
|
||||||
state.ema?.idpSession && { idpSession: state.ema.idpSession }),
|
state.ema?.idpSession && { idpSession: state.ema.idpSession }),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -334,7 +334,7 @@ When the user connects to a server with `oauth.enterpriseManaged: true` but inst
|
||||||
- **IdP session** (EMA only) — `none` / `logged_in` / `expired` from `idpSessions[issuer]`
|
- **IdP session** (EMA only) — `none` / `logged_in` / `expired` from `idpSessions[issuer]`
|
||||||
- **Auth URL** — cached from an in-flight or completed OAuth flow (`OAuthFlowState`), when present
|
- **Auth URL** — cached from an in-flight or completed OAuth flow (`OAuthFlowState`), when present
|
||||||
- **Scopes** — configured vs granted
|
- **Scopes** — configured vs granted
|
||||||
- **Access token** — `OAuthAccessTokenField`: copy (raw), decode JWT in place (`core/auth/ema/jwt.ts` helpers); multi-line wrap with segment-aware breaking for JWTs
|
- **Access token** (and **ID token**, when the authorization server returned an `id_token`) — `OAuthTokenField`: copy (raw), decode JWT in place (`core/auth/ema/jwt.ts` helpers); multi-line wrap with segment-aware breaking for JWTs. Display only — the `id_token` is never treated as a credential (#2019)
|
||||||
|
|
||||||
In-flight OAuth flow state uses `OAuthFlowState` / `getOAuthFlowState()` / `getOAuthFlowStep()`. `getOAuthState()` is the persisted connection snapshot; flow state is separate and ephemeral.
|
In-flight OAuth flow state uses `OAuthFlowState` / `getOAuthFlowState()` / `getOAuthFlowStep()`. `getOAuthState()` is the persisted connection snapshot; flow state is separate and ephemeral.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@ Shown as a modal or dedicated screen after successful connection.
|
||||||
- Server capabilities: Tools, Resources, Prompts, Logging, Completions, Tasks, Experimental
|
- Server capabilities: Tools, Resources, Prompts, Logging, Completions, Tasks, Experimental
|
||||||
- Client capabilities: Sampling, Elicitation, Roots, Tasks, Experimental
|
- Client capabilities: Sampling, Elicitation, Roots, Tasks, Experimental
|
||||||
- Display server instructions if provided
|
- Display server instructions if provided
|
||||||
- Show OAuth connection snapshot when applicable (`InspectorClient.getOAuthState()` → `OAuthConnectionState`): protocol, authorization status, client id, EMA IdP session, cached auth URL, scopes, access token with copy and in-place JWT decode (`OAuthAccessTokenField`)
|
- Show OAuth connection snapshot when applicable (`InspectorClient.getOAuthState()` → `OAuthConnectionState`): protocol, authorization status, client id, EMA IdP session, cached auth URL, scopes, and the stored tokens — access token, plus the `id_token` when the authorization server returned one — each with copy and in-place JWT decode (`OAuthTokenField`)
|
||||||
|
|
||||||
### Feature Screens
|
### Feature Screens
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue