From 07cafeedbcc28a6f07b12d5d0958398f9d4c6cc9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 15 Aug 2026 18:33:41 -0400 Subject: [PATCH] feat: surface and decode the OAuth id_token in Connection Info (#2019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ConnectionInfoContent.stories.tsx | 34 +++++++++++- .../ConnectionInfoContent.test.tsx | 54 +++++++++++++++++++ .../ConnectionInfoContent.tsx | 38 ++++++++----- ...ield.test.tsx => OAuthTokenField.test.tsx} | 44 +++++++++++---- ...cessTokenField.tsx => OAuthTokenField.tsx} | 35 +++++++----- .../oauthDetailsFromConnectionState.test.ts | 33 ++++++++++++ .../oauthDetailsFromConnectionState.ts | 5 ++ specification/v2_auth_ema.md | 2 +- specification/v2_ux.md | 2 +- 9 files changed, 211 insertions(+), 36 deletions(-) rename clients/web/src/components/groups/ConnectionInfoContent/{OAuthAccessTokenField.test.tsx => OAuthTokenField.test.tsx} (66%) rename clients/web/src/components/groups/ConnectionInfoContent/{OAuthAccessTokenField.tsx => OAuthTokenField.tsx} (74%) diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx index 3b1c919b..a634b9de 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx @@ -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, @@ -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(); + }, +}; diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index 9f9a8ea4..e33c63ad 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + expect(screen.getByText("Access Token")).toBeInTheDocument(); + expect(screen.queryByText("ID Token")).not.toBeInTheDocument(); }); it("does not render OAuth section when oauth prop is omitted", () => { diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx index a7cd53f2..dde264b6 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx @@ -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"; @@ -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"; } @@ -370,20 +377,27 @@ export function ConnectionInfoContent({ {formatScopes(oauth.scopes)} )} - {oauth.accessToken ? ( - - ) : ( - onClearOAuth && ( - - - {CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL} - - - ) + )} + {/* 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 && ( + + )} + {!oauth.accessToken && onClearOAuth && ( + + + {CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL} + + )} diff --git a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.test.tsx similarity index 66% rename from clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.test.tsx rename to clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.test.tsx index f1380f12..188c05a7 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.test.tsx @@ -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(); + renderWithMantine(); expect(screen.getByText("Access Token")).toBeInTheDocument(); expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument(); expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument(); @@ -17,7 +17,7 @@ describe("OAuthAccessTokenField", () => { it("replaces raw token with decoded JSON and restores on toggle", async () => { const user = userEvent.setup(); - renderWithMantine(); + renderWithMantine(); expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Decode JWT" })); @@ -31,7 +31,10 @@ describe("OAuthAccessTokenField", () => { it("omits decode toggle for opaque tokens", () => { renderWithMantine( - , + , ); expect( screen.queryByRole("button", { name: "Decode JWT" }), @@ -46,7 +49,7 @@ describe("OAuthAccessTokenField", () => { configurable: true, }); - renderWithMantine(); + renderWithMantine(); await user.click(screen.getByRole("button", { name: "Decode JWT" })); await user.click(screen.getByRole("button", { name: "Copy" })); @@ -64,7 +67,7 @@ describe("OAuthAccessTokenField", () => { configurable: true, }); - renderWithMantine(); + renderWithMantine(); await user.click(screen.getByRole("button", { name: "Copy" })); expect(writeText).toHaveBeenCalledWith(jwt); @@ -74,8 +77,9 @@ describe("OAuthAccessTokenField", () => { const user = userEvent.setup(); const onClear = vi.fn(); renderWithMantine( - , @@ -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(); + + 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(); + expect( + screen.queryByRole("button", { + name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL, + }), + ).not.toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.tsx b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx similarity index 74% rename from clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.tsx rename to clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx index 825a8507..a9c19b2d 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx @@ -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; } @@ -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(() => { @@ -85,12 +96,12 @@ export function OAuthAccessTokenField({ ); }, [jwtDecoded]); - const copyValue = showDecoded && decodedText ? decodedText : accessToken; + const copyValue = showDecoded && decodedText ? decodedText : token; return ( - Access Token + {label} {jwtDecoded && ( + ) : ( - accessToken + token )} diff --git a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts index d6a89da6..d047ab23 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts +++ b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts @@ -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, diff --git a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts index 14c8847e..2e514633 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts +++ b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts @@ -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 }), }; diff --git a/specification/v2_auth_ema.md b/specification/v2_auth_ema.md index a7a4c917..bfdb4358 100644 --- a/specification/v2_auth_ema.md +++ b/specification/v2_auth_ema.md @@ -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. diff --git a/specification/v2_ux.md b/specification/v2_ux.md index 08c37b9b..675e9472 100644 --- a/specification/v2_ux.md +++ b/specification/v2_ux.md @@ -294,7 +294,7 @@ Shown as a modal or dedicated screen after successful connection. - Server capabilities: Tools, Resources, Prompts, Logging, Completions, Tasks, Experimental - Client capabilities: Sampling, Elicitation, Roots, Tasks, Experimental - 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