All of the mid-session and step-up auth work
This commit is contained in:
parent
1f31e0f1f8
commit
562bdd00f1
9 changed files with 11437 additions and 1212 deletions
44
clients/web/src/utils/browserTabVisibility.test.ts
Normal file
44
clients/web/src/utils/browserTabVisibility.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
20
clients/web/src/utils/browserTabVisibility.ts
Normal file
20
clients/web/src/utils/browserTabVisibility.ts
Normal 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);
|
||||
}
|
||||
24
clients/web/src/utils/inspectorTabs.ts
Normal file
24
clients/web/src/utils/inspectorTabs.ts
Normal 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);
|
||||
}
|
||||
225
clients/web/src/utils/oauthResume.test.ts
Normal file
225
clients/web/src/utils/oauthResume.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
278
clients/web/src/utils/oauthResume.ts
Normal file
278
clients/web/src/utils/oauthResume.ts
Normal 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();
|
||||
}
|
||||
80
clients/web/src/utils/oauthUx.test.ts
Normal file
80
clients/web/src/utils/oauthUx.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
12
clients/web/src/utils/oauthUx.ts
Normal file
12
clients/web/src/utils/oauthUx.ts
Normal 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";
|
||||
18
clients/web/src/utils/pendingReauth.test.ts
Normal file
18
clients/web/src/utils/pendingReauth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
20
clients/web/src/utils/pendingReauth.ts
Normal file
20
clients/web/src/utils/pendingReauth.ts
Normal 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.
|
||||
*/
|
||||
Loading…
Reference in a new issue