Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {
InitializeResult,
} from "@modelcontextprotocol/client";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { expect, userEvent, within } from "storybook/test";
import {
ConnectionInfoContent,
SERVER_INFO_NOT_REPORTED_LABEL,
Expand Down Expand Up @@ -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();
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,60 @@ describe("ConnectionInfoContent", () => {
expect(screen.queryByText("Auth URL")).not.toBeInTheDocument();
expect(screen.queryByText("Scopes")).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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { EraBadge } from "../../elements/EraBadge/EraBadge";
import { isModernEra } from "../../elements/EraBadge/eraUtils";
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
import { OAuthTokenField } from "./OAuthTokenField";

export interface OAuthDetails {
protocol: "standard" | "ema";
Expand All @@ -34,6 +34,13 @@ export interface OAuthDetails {
authUrl?: string;
scopes?: 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. */
idpSession?: "none" | "logged_in" | "expired";
}
Expand Down Expand Up @@ -370,20 +377,27 @@ export function ConnectionInfoContent({
<ValueText>{formatScopes(oauth.scopes)}</ValueText>
</SimpleGrid>
)}
{oauth.accessToken ? (
<OAuthAccessTokenField
accessToken={oauth.accessToken}
{oauth.accessToken && (
<OAuthTokenField
label="Access Token"
token={oauth.accessToken}
onClear={onClearOAuth}
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
/>
) : (
onClearOAuth && (
<Flex justify="flex-end">
<ClearOAuthButton onClick={onClearOAuth}>
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
</ClearOAuthButton>
</Flex>
)
)}
{/* Viewer only — an `id_token` the AS happened to return, decoded
on request. It carries no clear action: clearing OAuth state is
one action for the whole token set, owned by the access-token
row (or the standalone button below when there is none). */}
{oauth.idToken && (
<OAuthTokenField label="ID Token" token={oauth.idToken} />
Comment thread
cliffhall marked this conversation as resolved.
)}
{!oauth.accessToken && onClearOAuth && (
<Flex justify="flex-end">
<ClearOAuthButton onClick={onClearOAuth}>
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
</ClearOAuthButton>
</Flex>
)}
</Stack>
</Stack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ 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 { OAuthAccessTokenField } from "./OAuthAccessTokenField";
import { OAuthTokenField } from "./OAuthTokenField";

const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";

describe("OAuthAccessTokenField", () => {
describe("OAuthTokenField", () => {
it("renders token with copy control beside the content", () => {
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
expect(screen.getByText("Access Token")).toBeInTheDocument();
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument();
Expand All @@ -17,7 +17,7 @@ describe("OAuthAccessTokenField", () => {

it("replaces raw token with decoded JSON and restores on toggle", async () => {
const user = userEvent.setup();
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);

expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
Expand All @@ -31,7 +31,10 @@ describe("OAuthAccessTokenField", () => {

it("omits decode toggle for opaque tokens", () => {
renderWithMantine(
<OAuthAccessTokenField accessToken="opaque-access-token-value" />,
<OAuthTokenField
label="Access Token"
token="opaque-access-token-value"
/>,
);
expect(
screen.queryByRole("button", { name: "Decode JWT" }),
Expand All @@ -46,7 +49,7 @@ describe("OAuthAccessTokenField", () => {
configurable: true,
});

renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
await user.click(screen.getByRole("button", { name: "Copy" }));

Expand All @@ -64,7 +67,7 @@ describe("OAuthAccessTokenField", () => {
configurable: true,
});

renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
await user.click(screen.getByRole("button", { name: "Copy" }));

expect(writeText).toHaveBeenCalledWith(jwt);
Expand All @@ -74,8 +77,9 @@ describe("OAuthAccessTokenField", () => {
const user = userEvent.setup();
const onClear = vi.fn();
renderWithMantine(
<OAuthAccessTokenField
accessToken="opaque-access-token-value"
<OAuthTokenField
label="Access Token"
token="opaque-access-token-value"
onClear={onClear}
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
/>,
Expand All @@ -87,4 +91,26 @@ describe("OAuthAccessTokenField", () => {
);
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@ 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";

export interface OAuthAccessTokenFieldProps {
accessToken: string;
/**
* 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;
}
Expand Down Expand Up @@ -64,16 +74,17 @@ function JwtTokenText({ token }: { token: string }) {
);
}

export function OAuthAccessTokenField({
accessToken,
export function OAuthTokenField({
label,
token,
onClear,
clearLabel = "Clear",
}: OAuthAccessTokenFieldProps) {
}: OAuthTokenFieldProps) {
const [showDecoded, setShowDecoded] = useState(false);
const isJwt = isJwtFormat(accessToken);
const isJwt = isJwtFormat(token);
const jwtDecoded = useMemo(
() => (isJwt ? decodeJwtPayload(accessToken) : undefined),
[accessToken, isJwt],
() => (isJwt ? decodeJwtPayload(token) : undefined),
[token, isJwt],
);

const decodedText = useMemo(() => {
Expand All @@ -85,12 +96,12 @@ export function OAuthAccessTokenField({
);
}, [jwtDecoded]);

const copyValue = showDecoded && decodedText ? decodedText : accessToken;
const copyValue = showDecoded && decodedText ? decodedText : token;

return (
<Stack gap="xs">
<CaptionRow>
<Caption>Access Token</Caption>
<Caption>{label}</Caption>
<Toolbar>
{jwtDecoded && (
<ToolbarButton
Expand All @@ -113,9 +124,9 @@ export function OAuthAccessTokenField({
{showDecoded && decodedText ? (
decodedText
) : isJwt ? (
<JwtTokenText token={accessToken} />
<JwtTokenText token={token} />
) : (
accessToken
token
)}
</TokenCode>
</TokenColumn>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
const state: OAuthConnectionState = {
authorized: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ export function oauthDetailsFromConnectionState(
...(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.ema?.idpSession && { idpSession: state.ema.idpSession }),
};
Expand Down
2 changes: 1 addition & 1 deletion specification/v2_auth_ema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`
- **Auth URL** — cached from an in-flight or completed OAuth flow (`OAuthFlowState`), when present
- **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.

Expand Down
Loading
Loading