All of the mid-session and step-up auth work

This commit is contained in:
Bob Dickinson 2026-07-02 16:35:31 -07:00
commit 562bdd00f1
9 changed files with 11437 additions and 1212 deletions

View file

@ -0,0 +1,44 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
isBrowserTabVisible,
onBrowserTabVisible,
} from "./browserTabVisibility.js";
describe("browserTabVisibility", () => {
beforeEach(() => {
vi.stubGlobal("document", {
visibilityState: "visible",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("isBrowserTabVisible reflects document.visibilityState", () => {
expect(isBrowserTabVisible()).toBe(true);
(document as { visibilityState: string }).visibilityState = "hidden";
expect(isBrowserTabVisible()).toBe(false);
});
it("onBrowserTabVisible invokes callback when becoming visible", () => {
let handler: (() => void) | undefined;
vi.mocked(document.addEventListener).mockImplementation((_event, fn) => {
handler = fn as () => void;
});
const callback = vi.fn();
onBrowserTabVisible(callback);
expect(handler).toBeDefined();
(document as { visibilityState: string }).visibilityState = "hidden";
handler!();
expect(callback).not.toHaveBeenCalled();
(document as { visibilityState: string }).visibilityState = "visible";
handler!();
expect(callback).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,20 @@
/** True when this browser tab is the foreground tab (Page Visibility API). */
export function isBrowserTabVisible(): boolean {
return (
typeof document !== "undefined" && document.visibilityState === "visible"
);
}
/** Subscribe to this browser tab becoming visible. Returns an unsubscribe function. */
export function onBrowserTabVisible(callback: () => void): () => void {
if (typeof document === "undefined") {
return () => {};
}
const handler = (): void => {
if (document.visibilityState === "visible") {
callback();
}
};
document.addEventListener("visibilitychange", handler);
return () => document.removeEventListener("visibilitychange", handler);
}

View file

@ -0,0 +1,24 @@
/**
* Inspector main-view tab identifiers. Match labels used in ViewHeader /
* InspectorView (`"Tools"`, `"Resources"`, …).
*/
export const INSPECTOR_SERVERS_TAB = "Servers";
/** Tabs with liftable `*UiState` in App.tsx (Servers has no ui snapshot). */
export const INSPECTOR_TAB_IDS = [
"Apps",
"Tools",
"Prompts",
"Resources",
"Tasks",
"Logs",
"History",
"Network",
] as const;
export type InspectorTabId = (typeof INSPECTOR_TAB_IDS)[number];
export function isInspectorTabId(value: string): value is InspectorTabId {
return (INSPECTOR_TAB_IDS as readonly string[]).includes(value);
}

View file

@ -0,0 +1,225 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
applyOAuthResumeUi,
buildTabUiSnapshot,
clearOAuthResumeSnapshot,
consumeOAuthResumeSnapshot,
oauthResumeInsufficientScopeMessage,
oauthResumeToastMessage,
OAUTH_PENDING_SERVER_KEY,
readOAuthResumeSnapshot,
restoreTabUiFromSnapshot,
writeOAuthResumeSnapshot,
type OAuthResumeSnapshot,
} from "./oauthResume.js";
import {
EMPTY_TOOLS_UI,
EMPTY_PROMPTS_UI,
EMPTY_RESOURCES_UI,
EMPTY_APPS_UI,
EMPTY_TASKS_UI,
EMPTY_LOGS_UI,
EMPTY_HISTORY_UI,
EMPTY_NETWORK_UI,
} from "../components/screens/screenUiState.js";
describe("oauthResume", () => {
const storage = new Map<string, string>();
beforeEach(() => {
storage.clear();
vi.stubGlobal("sessionStorage", {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("consumeOAuthResumeSnapshot reads once then clears storage", () => {
const snapshot: OAuthResumeSnapshot = {
version: 1,
serverId: "srv-1",
activeTab: "Tools",
authKind: "reauth",
tabUi: {},
};
writeOAuthResumeSnapshot(snapshot);
expect(consumeOAuthResumeSnapshot()).toEqual(snapshot);
expect(readOAuthResumeSnapshot()).toBeUndefined();
expect(consumeOAuthResumeSnapshot()).toBeUndefined();
});
it("clearOAuthResumeSnapshot removes pending redirect state (explicit disconnect)", () => {
writeOAuthResumeSnapshot({
version: 1,
serverId: "srv-1",
activeTab: "Tools",
authKind: "reauth",
tabUi: {},
});
clearOAuthResumeSnapshot();
expect(readOAuthResumeSnapshot()).toBeUndefined();
expect(consumeOAuthResumeSnapshot()).toBeUndefined();
});
it("round-trips OAuthResumeSnapshot", () => {
const snapshot: OAuthResumeSnapshot = {
version: 1,
serverId: "srv-1",
activeTab: "Tools",
authKind: "step_up",
tabUi: {
Tools: {
...EMPTY_TOOLS_UI,
selectedToolName: "echo",
formValues: { message: "hi" },
},
},
remoteSessionId: "remote-abc",
};
writeOAuthResumeSnapshot(snapshot);
expect(readOAuthResumeSnapshot()).toEqual(snapshot);
expect(storage.get(OAUTH_PENDING_SERVER_KEY)).toBeUndefined();
clearOAuthResumeSnapshot();
expect(readOAuthResumeSnapshot()).toBeUndefined();
});
it("builds and restores tab ui snapshots", () => {
const toolsUi = {
...EMPTY_TOOLS_UI,
selectedToolName: "get_temp",
formValues: { city: "NYC" },
};
const tabUi = buildTabUiSnapshot({
toolsUi,
promptsUi: EMPTY_PROMPTS_UI,
resourcesUi: EMPTY_RESOURCES_UI,
appsUi: EMPTY_APPS_UI,
tasksUi: EMPTY_TASKS_UI,
logsUi: EMPTY_LOGS_UI,
historyUi: EMPTY_HISTORY_UI,
networkUi: EMPTY_NETWORK_UI,
});
const setToolsUi = vi.fn();
restoreTabUiFromSnapshot(tabUi, {
setToolsUi,
setPromptsUi: vi.fn(),
setResourcesUi: vi.fn(),
setAppsUi: vi.fn(),
setTasksUi: vi.fn(),
setLogsUi: vi.fn(),
setHistoryUi: vi.fn(),
setNetworkUi: vi.fn(),
});
expect(setToolsUi).toHaveBeenCalledWith(toolsUi);
});
it("falls back to legacy pending server key", () => {
storage.set(OAUTH_PENDING_SERVER_KEY, "legacy-srv");
const snapshot = readOAuthResumeSnapshot();
expect(snapshot?.serverId).toBe("legacy-srv");
expect(snapshot?.activeTab).toBe("Servers");
expect(snapshot?.authKind).toBe("reauth");
});
it("returns toast copy by auth kind", () => {
expect(oauthResumeToastMessage("step_up", { recoverySource: "tool" })).toBe(
"Step-up authorization succeeded. Retry your action.",
);
expect(oauthResumeToastMessage("step_up")).toBe(
"Step-up authorization succeeded.",
);
expect(oauthResumeToastMessage("reauth", { recoverySource: "tool" })).toBe(
"Authentication succeeded. Retry your action.",
);
expect(oauthResumeToastMessage("reauth")).toBe("Authentication succeeded.");
});
it("returns insufficient-scope message with tool context", () => {
expect(
oauthResumeInsufficientScopeMessage({
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
context: { toolName: "get_temp" },
}),
).toMatch(/get_temp/);
});
it("round-trips authChallenge on step-up snapshot", () => {
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["weather:read"],
context: { toolName: "get_temp" },
};
const snapshot: OAuthResumeSnapshot = {
version: 1,
serverId: "srv-1",
activeTab: "Tools",
authKind: "step_up",
tabUi: {},
authChallenge: challenge,
};
writeOAuthResumeSnapshot(snapshot);
expect(readOAuthResumeSnapshot()?.authChallenge).toEqual(challenge);
});
it("rejects snapshots with invalid tabUi keys", () => {
writeOAuthResumeSnapshot({
version: 1,
serverId: "srv-1",
activeTab: "Tools",
authKind: "reauth",
tabUi: { NotATab: {} },
} as OAuthResumeSnapshot);
expect(readOAuthResumeSnapshot()).toBeUndefined();
});
it("applyOAuthResumeUi restores tab ui, active tab, and clears in-flight panels", () => {
const toolsUi = {
...EMPTY_TOOLS_UI,
selectedToolName: "get_temp",
formValues: { city: "NYC" },
};
const snapshot: OAuthResumeSnapshot = {
version: 1,
serverId: "srv-1",
activeTab: "Tools",
authKind: "step_up",
tabUi: { Tools: toolsUi },
};
const setToolsUi = vi.fn();
const setActiveTab = vi.fn();
const clearToolCallState = vi.fn();
const clearGetPromptState = vi.fn();
const clearReadResourceState = vi.fn();
applyOAuthResumeUi(snapshot, {
setToolsUi,
setPromptsUi: vi.fn(),
setResourcesUi: vi.fn(),
setAppsUi: vi.fn(),
setTasksUi: vi.fn(),
setLogsUi: vi.fn(),
setHistoryUi: vi.fn(),
setNetworkUi: vi.fn(),
setActiveTab,
clearToolCallState,
clearGetPromptState,
clearReadResourceState,
});
expect(setToolsUi).toHaveBeenCalledWith(toolsUi);
expect(setActiveTab).toHaveBeenCalledWith("Tools");
expect(clearToolCallState).toHaveBeenCalledOnce();
expect(clearGetPromptState).toHaveBeenCalledOnce();
expect(clearReadResourceState).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,278 @@
/**
* Persist inspector shell state across full-page OAuth redirects.
* Serializes only liftable `*UiState` shells — not message logs, fetch bodies,
* tool results, or managed primitive lists.
*/
import {
EMPTY_APPS_UI,
EMPTY_HISTORY_UI,
EMPTY_LOGS_UI,
EMPTY_NETWORK_UI,
EMPTY_PROMPTS_UI,
EMPTY_RESOURCES_UI,
EMPTY_TASKS_UI,
EMPTY_TOOLS_UI,
} from "../components/screens/screenUiState.js";
import type { AppsUiState } from "../components/screens/AppsScreen/AppsScreen.js";
import type { HistoryUiState } from "../components/screens/HistoryScreen/HistoryScreen.js";
import type { LogsUiState } from "../components/screens/LoggingScreen/LoggingScreen.js";
import type { NetworkUiState } from "../components/screens/NetworkScreen/NetworkScreen.js";
import type { PromptsUiState } from "../components/screens/PromptsScreen/PromptsScreen.js";
import type { ResourcesUiState } from "../components/screens/ResourcesScreen/ResourcesScreen.js";
import type { TasksUiState } from "../components/screens/TasksScreen/TasksScreen.js";
import type { ToolsUiState } from "../components/screens/ToolsScreen/ToolsScreen.js";
import {
INSPECTOR_SERVERS_TAB,
type InspectorTabId,
isInspectorTabId,
} from "./inspectorTabs.js";
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import {
oauthResumeSuccessMessage,
stepUpInsufficientScopeMessage,
type OAuthRecoverySource,
} from "@inspector/core/auth/oauthUx.js";
import { OAUTH_PENDING_SERVER_KEY } from "./oauthFlow.js";
export const OAUTH_RESUME_KEY = "mcp-inspector:oauth-resume";
export { OAUTH_PENDING_SERVER_KEY };
export type OAuthResumeAuthKind = "step_up" | "reauth";
export interface OAuthResumeSnapshot {
version: 1;
serverId: string;
activeTab: string;
authKind: OAuthResumeAuthKind;
/**
* Per-tab lifted UI state (`*UiState` only). Keys are {@link InspectorTabId}.
*/
tabUi: Partial<Record<InspectorTabId, unknown>>;
/** Hono remote session id for auth-state push after callback. */
remoteSessionId?: string;
/** Step-up challenge at redirect time; used to verify scope satisfaction after callback. */
authChallenge?: AuthChallenge;
/** Command-scoped recovery source when redirect was triggered by a user action. */
recoverySource?: OAuthRecoverySource;
}
export interface LiftedTabUiState {
toolsUi: ToolsUiState;
promptsUi: PromptsUiState;
resourcesUi: ResourcesUiState;
appsUi: AppsUiState;
tasksUi: TasksUiState;
logsUi: LogsUiState;
historyUi: HistoryUiState;
networkUi: NetworkUiState;
}
export interface TabUiSetters {
setToolsUi: (next: ToolsUiState) => void;
setPromptsUi: (next: PromptsUiState) => void;
setResourcesUi: (next: ResourcesUiState) => void;
setAppsUi: (next: AppsUiState) => void;
setTasksUi: (next: TasksUiState) => void;
setLogsUi: (next: LogsUiState) => void;
setHistoryUi: (next: HistoryUiState) => void;
setNetworkUi: (next: NetworkUiState) => void;
}
export function buildTabUiSnapshot(
state: LiftedTabUiState,
): Partial<Record<InspectorTabId, unknown>> {
return {
Apps: state.appsUi,
Tools: state.toolsUi,
Prompts: state.promptsUi,
Resources: state.resourcesUi,
Tasks: state.tasksUi,
Logs: state.logsUi,
History: state.historyUi,
Network: state.networkUi,
};
}
export function restoreTabUiFromSnapshot(
tabUi: Partial<Record<InspectorTabId, unknown>> | undefined,
setters: TabUiSetters,
): void {
if (!tabUi) {
return;
}
for (const tabId of Object.keys(tabUi) as InspectorTabId[]) {
if (!isInspectorTabId(tabId)) {
continue;
}
const value = tabUi[tabId];
switch (tabId) {
case "Tools":
setters.setToolsUi(
(value as ToolsUiState | undefined) ?? EMPTY_TOOLS_UI,
);
break;
case "Prompts":
setters.setPromptsUi(
(value as PromptsUiState | undefined) ?? EMPTY_PROMPTS_UI,
);
break;
case "Resources":
setters.setResourcesUi(
(value as ResourcesUiState | undefined) ?? EMPTY_RESOURCES_UI,
);
break;
case "Apps":
setters.setAppsUi((value as AppsUiState | undefined) ?? EMPTY_APPS_UI);
break;
case "Tasks":
setters.setTasksUi(
(value as TasksUiState | undefined) ?? EMPTY_TASKS_UI,
);
break;
case "Logs":
setters.setLogsUi((value as LogsUiState | undefined) ?? EMPTY_LOGS_UI);
break;
case "History":
setters.setHistoryUi(
(value as HistoryUiState | undefined) ?? EMPTY_HISTORY_UI,
);
break;
case "Network":
setters.setNetworkUi(
(value as NetworkUiState | undefined) ?? EMPTY_NETWORK_UI,
);
break;
default: {
const _exhaustive: never = tabId;
void _exhaustive;
}
}
}
}
export function writeOAuthResumeSnapshot(snapshot: OAuthResumeSnapshot): void {
if (typeof window === "undefined") {
return;
}
try {
window.sessionStorage.setItem(OAUTH_RESUME_KEY, JSON.stringify(snapshot));
} catch {
// Best-effort — privacy mode / quota.
}
}
export function readOAuthResumeSnapshot(): OAuthResumeSnapshot | undefined {
if (typeof window === "undefined") {
return undefined;
}
try {
const raw = window.sessionStorage.getItem(OAUTH_RESUME_KEY);
if (!raw) {
return readLegacyPendingServerSnapshot();
}
const parsed = JSON.parse(raw) as OAuthResumeSnapshot;
if (
parsed?.version !== 1 ||
typeof parsed.serverId !== "string" ||
!isOAuthResumeAuthKind(parsed.authKind) ||
typeof parsed.activeTab !== "string" ||
!isValidTabUiSnapshot(parsed.tabUi)
) {
return undefined;
}
return parsed;
} catch {
return undefined;
}
}
/** Read the pending snapshot and remove it from storage (one-shot). */
export function consumeOAuthResumeSnapshot(): OAuthResumeSnapshot | undefined {
const snapshot = readOAuthResumeSnapshot();
if (snapshot) {
clearOAuthResumeSnapshot();
}
return snapshot;
}
function readLegacyPendingServerSnapshot(): OAuthResumeSnapshot | undefined {
try {
const serverId = window.sessionStorage.getItem(OAUTH_PENDING_SERVER_KEY);
if (!serverId) {
return undefined;
}
return {
version: 1,
serverId,
activeTab: INSPECTOR_SERVERS_TAB,
authKind: "reauth",
tabUi: {},
};
} catch {
return undefined;
}
}
export function clearOAuthResumeSnapshot(): void {
if (typeof window === "undefined") {
return;
}
try {
window.sessionStorage.removeItem(OAUTH_RESUME_KEY);
window.sessionStorage.removeItem(OAUTH_PENDING_SERVER_KEY);
} catch {
// ignore
}
}
export function oauthResumeToastMessage(
authKind: OAuthResumeAuthKind,
options?: { recoverySource?: OAuthRecoverySource },
): string {
return oauthResumeSuccessMessage(authKind, options);
}
/** Post-callback copy when step-up OAuth completed but scopes still do not satisfy the challenge. */
export function oauthResumeInsufficientScopeMessage(
challenge: AuthChallenge,
): string {
return stepUpInsufficientScopeMessage(challenge);
}
function isOAuthResumeAuthKind(value: unknown): value is OAuthResumeAuthKind {
return value === "step_up" || value === "reauth";
}
function isValidTabUiSnapshot(
tabUi: unknown,
): tabUi is Partial<Record<InspectorTabId, unknown>> {
if (tabUi === undefined) {
return true;
}
if (typeof tabUi !== "object" || tabUi === null || Array.isArray(tabUi)) {
return false;
}
return Object.keys(tabUi).every((key) => isInspectorTabId(key));
}
/** Setters used when restoring App shell state after `/oauth/callback`. */
export interface OAuthResumeUiSetters extends TabUiSetters {
setActiveTab: (tab: string) => void;
clearToolCallState: () => void;
clearGetPromptState: () => void;
clearReadResourceState: () => void;
}
/** Restore tab selection, per-tab UI, and clear in-flight result panels. One-shot: callers must not invoke twice for the same redirect. */
export function applyOAuthResumeUi(
snapshot: OAuthResumeSnapshot,
setters: OAuthResumeUiSetters,
): void {
restoreTabUiFromSnapshot(snapshot.tabUi, setters);
setters.setActiveTab(snapshot.activeTab);
setters.clearToolCallState();
setters.clearGetPromptState();
setters.clearReadResourceState();
}

View file

@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";
import {
authRecoveryRestoredMessage,
isReAuthBannerReason,
oauthPreRedirectToastCopy,
oauthResumeSuccessMessage,
reAuthBannerMessage,
} from "./oauthUx.js";
describe("oauthUx", () => {
it("returns pre-redirect toast for standard OAuth reauth", () => {
expect(oauthPreRedirectToastCopy("reauth", { serverName: "srv" })).toEqual({
title: 'Session expired for "srv"',
message: "Session expired, re-authenticating…",
});
});
it("returns no pre-redirect toast for connect-time OAuth", () => {
expect(
oauthPreRedirectToastCopy("reauth", {
serverName: "srv",
context: "connect",
}),
).toBeUndefined();
expect(
oauthPreRedirectToastCopy("reauth", {
serverName: "srv",
enterpriseManaged: true,
context: "connect",
}),
).toBeUndefined();
});
it("returns pre-redirect toast for EMA reauth", () => {
expect(
oauthPreRedirectToastCopy("reauth", {
serverName: "srv",
enterpriseManaged: true,
}),
).toEqual({
title: 'Re-authenticating "srv"',
message: "Re-authenticating…",
});
});
it("returns pre-redirect toast for step-up", () => {
expect(oauthPreRedirectToastCopy("step_up", { serverName: "srv" })).toEqual(
{
title: 'Step-up authorization for "srv"',
message: "Redirecting to authorize additional permissions…",
},
);
});
it("identifies re-auth banner challenge reasons", () => {
expect(isReAuthBannerReason("unauthorized")).toBe(true);
expect(isReAuthBannerReason("token_expired")).toBe(true);
expect(isReAuthBannerReason("invalid_token")).toBe(true);
expect(isReAuthBannerReason("insufficient_scope")).toBe(false);
});
it("builds re-auth banner message", () => {
expect(
reAuthBannerMessage({ serverName: "demo", detail: "Sign in again." }),
).toBe('Authentication for "demo" needs attention. Sign in again.');
});
it("returns success toast copy only for action-triggered recovery", () => {
expect(
oauthResumeSuccessMessage("reauth", { recoverySource: "tool" }),
).toMatch(/Retry your action/);
expect(oauthResumeSuccessMessage("reauth")).not.toMatch(
/Retry your action/,
);
expect(authRecoveryRestoredMessage({ recoverySource: "prompt" })).toMatch(
/Retry your action/,
);
expect(authRecoveryRestoredMessage()).not.toMatch(/Retry your action/);
});
});

View file

@ -0,0 +1,12 @@
export {
authRecoveryRestoredMessage,
isActionTriggeredOAuthRecovery,
oauthPreRedirectToastCopy,
oauthResumeAbandonedMessage,
oauthResumeSuccessMessage,
isReAuthBannerReason,
reAuthBannerMessage,
type OAuthInteractiveAuthKind,
type OAuthPreRedirectContext,
type OAuthRecoverySource,
} from "@inspector/core/auth/oauthUx.js";

View file

@ -0,0 +1,18 @@
import { describe, it, expect } from "vitest";
import type { PendingReauth } from "./pendingReauth.js";
describe("PendingReauth", () => {
it("carries step-up source through deferred resume", () => {
const pending: PendingReauth = {
serverId: "srv-1",
challenge: {
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
},
authorizationUrl: new URL("https://as.example/authorize"),
authKind: "step_up",
source: "tool",
};
expect(pending.source).toBe("tool");
});
});

View file

@ -0,0 +1,20 @@
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import type { OAuthRecoverySource } from "@inspector/core/auth/oauthUx.js";
import type { OAuthResumeAuthKind } from "./oauthResume.js";
/** Origin of a deferred or resumed auth recovery flow (matches web `StepUpSource`). */
export type PendingReauthSource = OAuthRecoverySource;
/** Deferred ambient interactive recovery for a background browser tab. */
export interface PendingReauth {
serverId: string;
challenge: AuthChallenge;
authorizationUrl: URL;
authKind: OAuthResumeAuthKind;
source: PendingReauthSource;
}
/**
* In-memory only — survives tab visibility changes but not a full page reload.
* OAuth resume snapshot is written only once interactive redirect starts.
*/