Copilot reported "no new comments" again, with four real findings in its suppressed block. - The web in-flight lock was global, so a pending clear for server A silently swallowed a click on server B's — even though the callback explicitly supports clearing a non-active server. Keyed by server id now. - `else if (!isActive)` skipped the resume-snapshot cleanup when the target IS active but has no client yet, which is reachable while a session is being built or torn down. The clear then emptied storage and left stale OAuth recovery state pointing at credentials that no longer existed. - `revocationAuthMethods` returned `[]` for an omitted `revocation_endpoint_auth_methods_supported`, which does not enforce RFC 8414's default: an empty list means "the metadata said nothing" to `selectClientAuthMethod`, which then honors the registration's own `token_endpoint_auth_method`. The default is named literally now. - The `v8 ignore` on the CLI budget branch was wrong, and the challenge to it was right: an injected `budgetMs` of zero reaches it directly, through a seam I had added myself the round before. Removed, and covered by a test that asserts no request is made and the unattempted key is reported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
1457 lines
51 KiB
TypeScript
1457 lines
51 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import type { RefObject } from "react";
|
|
import { notifications } from "@mantine/notifications";
|
|
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
|
import type { TypedEvent } from "@inspector/core/mcp/inspectorClientEventTarget.js";
|
|
import type {
|
|
ConnectionStatus,
|
|
InspectorServerSettings,
|
|
MCPServerConfig,
|
|
ServerEntry,
|
|
} from "@inspector/core/mcp/types.js";
|
|
import type { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js";
|
|
import {
|
|
formatOAuthFailureDetail,
|
|
generateOAuthErrorDescription,
|
|
parseOAuthCallbackParams,
|
|
parseOAuthState,
|
|
} from "@inspector/core/auth/index.js";
|
|
import { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
|
|
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
|
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
|
|
import type {
|
|
AuthChallenge,
|
|
AuthChallengeReason,
|
|
} from "@inspector/core/auth/challenge.js";
|
|
import { findIssuerBindingFailure } from "@inspector/core/auth/issuerBinding.js";
|
|
import {
|
|
emaStepUpFailureMessage,
|
|
emaStepUpInProgressMessage,
|
|
emaStepUpSuccessMessage,
|
|
} from "@inspector/core/auth/oauthUx.js";
|
|
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
|
|
import type { OAuthDetails } from "../components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
|
import { oauthDetailsFromConnectionState } from "../components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
|
|
import { getWebRemoteOAuthStorage } from "../lib/remoteOAuthStorage";
|
|
import { getWebProxiedFetch } from "../lib/webProxiedFetch";
|
|
import { clearServerOAuthState } from "../lib/clearServerOAuthState";
|
|
import { getAuthToken } from "../lib/authToken";
|
|
import {
|
|
isBrowserTabVisible,
|
|
onBrowserTabVisible,
|
|
} from "../lib/browserTabVisibility";
|
|
import {
|
|
applyOAuthResumeUi,
|
|
buildTabUiSnapshot,
|
|
clearOAuthResumeSnapshot,
|
|
consumeOAuthResumeSnapshot,
|
|
oauthResumeInsufficientScopeMessage,
|
|
oauthResumeToastMessage,
|
|
writeOAuthResumeSnapshot,
|
|
type OAuthResumeAuthKind,
|
|
} from "../lib/oauthResume";
|
|
import { OAUTH_CALLBACK_PATH } from "../utils/oauthFlow";
|
|
import { INSPECTOR_SERVERS_TAB } from "../utils/inspectorTabs";
|
|
import type { PendingReauth } from "../utils/pendingReauth";
|
|
import {
|
|
authRecoveryRestoredMessage,
|
|
isReAuthBannerReason,
|
|
issuerBindingFailureCopy,
|
|
lostAuthorizationStateActionLabel,
|
|
oauthPreRedirectToastCopy,
|
|
oauthResumeAbandonedMessage,
|
|
reAuthBannerMessage,
|
|
type OAuthPreRedirectContext,
|
|
type OAuthRecoverySource,
|
|
} from "../utils/oauthUx";
|
|
import {
|
|
isEmaStepUp,
|
|
isStepUpConfirmation,
|
|
type PendingStepUp,
|
|
type StepUpSource,
|
|
} from "../utils/stepUp";
|
|
import type { SessionRef } from "./useSessionRef";
|
|
import type { TabUiState, TabUiStateSetters } from "./useTabUiState";
|
|
|
|
/** The banner raised when a session needs the user to authorize again. */
|
|
export interface ReAuthBannerState {
|
|
serverId: string;
|
|
message: string;
|
|
/**
|
|
* `lost_authorization_state` marks the SEP-2352 recovery case (#1808): the
|
|
* callback arrived with no recorded discovery state, so the banner offers
|
|
* "Authorize again", which drops the stale/partial OAuth state before
|
|
* starting a fresh authorization.
|
|
*/
|
|
kind?: "lost_authorization_state";
|
|
/**
|
|
* Resolved at the point the banner is raised so `issuerBindingFailureCopy`
|
|
* stays the single source of the `kind → copy` mapping; both fall back to
|
|
* `ReAuthBanner`'s own defaults when absent.
|
|
*/
|
|
title?: string;
|
|
actionLabel?: string;
|
|
}
|
|
|
|
/** Everything `prepareOAuthRedirect` needs to hand the browser to the AS. */
|
|
export interface PrepareOAuthRedirectArgs {
|
|
serverId: string;
|
|
authKind: OAuthResumeAuthKind;
|
|
authorizationUrl: URL;
|
|
authChallenge?: AuthChallenge;
|
|
recoverySource?: OAuthRecoverySource;
|
|
preRedirectContext?: OAuthPreRedirectContext;
|
|
client?: InspectorClient;
|
|
}
|
|
|
|
/**
|
|
* The only part of the Network log this hook touches: reading the entries to
|
|
* flush before a redirect. Naming that rather than the whole
|
|
* `FetchRequestLogState` states the real dependency, and lets a test supply a
|
|
* plain object instead of casting a partial one through `unknown` — the same
|
|
* shape `useProgressToasts` uses for its slice of `InspectorClient`.
|
|
*/
|
|
export type FetchRequestSource = Pick<FetchRequestLogState, "getFetchRequests">;
|
|
|
|
/** The one thing the `/oauth/callback` rebuild needs from the connect path. */
|
|
export type SetupClientForServer = (
|
|
server: ServerEntry,
|
|
sessionId?: string,
|
|
) => InspectorClient;
|
|
|
|
/** The subset of a server entry the OAuth clear path reads. */
|
|
export interface ClearableServer {
|
|
id: string;
|
|
name: string;
|
|
config: MCPServerConfig;
|
|
/**
|
|
* Read for `oauthRevokeOnClear` (#2144). Optional so a caller that only has
|
|
* the identity fields still type-checks; an absent value means the default,
|
|
* on.
|
|
*/
|
|
settings?: InspectorServerSettings;
|
|
}
|
|
|
|
/**
|
|
* The sentence appended to the "OAuth state cleared" toast describing what the
|
|
* RFC 7009 leg did (#2144).
|
|
*
|
|
* Only the two outcomes a user can act on are surfaced. A success is worth
|
|
* saying because the whole point of the feature is invisible otherwise — the
|
|
* local clear looks identical either way. A failure is worth saying because the
|
|
* grant is still live at the authorization server and the user may want to
|
|
* revoke it by hand. The remaining skips ("this server advertises no revocation
|
|
* endpoint", "there were no tokens") describe the status quo and would turn a
|
|
* confirmation into a notice about something that did not need to happen.
|
|
*/
|
|
export function revocationSuffix(
|
|
outcome: TokenRevocationOutcome | undefined,
|
|
): string {
|
|
if (outcome?.status === "revoked") {
|
|
return " The grant was also revoked at the authorization server.";
|
|
}
|
|
if (outcome?.status === "failed") {
|
|
return ` Revoking the grant at the authorization server failed (${outcome.detail}), so it may still be valid there.`;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
export interface UseOAuthRecoveryOptions {
|
|
/** The stable session mirror from `useSessionRef` (#2129). */
|
|
sessionRef: SessionRef;
|
|
servers: ServerEntry[];
|
|
activeServerId: string | undefined;
|
|
inspectorClient: InspectorClient | null;
|
|
connectionStatus: ConnectionStatus;
|
|
/** Captured into the resume snapshot so the callback restores the shell. */
|
|
activeTab: string;
|
|
ui: TabUiState;
|
|
setUi: TabUiStateSetters;
|
|
setActiveTab: (next: string) => void;
|
|
/** The live Network log, flushed to session storage before a redirect. */
|
|
fetchLogRef: RefObject<FetchRequestSource | null>;
|
|
/** Handshake telemetry, cleared/started around the callback's reconnect. */
|
|
connectStartRef: RefObject<number | undefined>;
|
|
/**
|
|
* Resolves once `/api/config` has settled. The callback rebuild waits on it
|
|
* for the same reason the connect path does: whether the client may
|
|
* advertise app-rendered elicitation is fixed at construction.
|
|
*/
|
|
initialConfigSettledRef: RefObject<{ promise: Promise<void> } | null>;
|
|
/**
|
|
* Rebuilds the client for the server that started the flow. Injected through
|
|
* a ref rather than as a value because it is declared *after* this hook runs
|
|
* (it reads `onBeforeOAuthRedirect` and `sessionStorageAdapter` from the
|
|
* return below), and it is only ever read from an effect.
|
|
*/
|
|
setupClientForServerRef: RefObject<SetupClientForServer | null>;
|
|
setActiveServerId: (id: string | undefined) => void;
|
|
/** Red-borders a server in the list (#1621). */
|
|
setFailedServerId: (id: string | undefined) => void;
|
|
/** Drops the in-flight result panels when the resume snapshot is applied. */
|
|
clearResultPanels: () => void;
|
|
/**
|
|
* Routes a step-up failure/cancellation to the panel that issued the
|
|
* command. A no-op for the sources with no panel (`app`, `ambient`).
|
|
*/
|
|
setSourceScopedError: (source: StepUpSource, message: string) => void;
|
|
}
|
|
|
|
export interface OAuthRecovery {
|
|
/** Shared OAuth runtime store (`oauth.json` via `/api/storage/oauth`). */
|
|
webOAuthStorage: ReturnType<typeof getWebRemoteOAuthStorage>;
|
|
/** Backend session storage carrying the Network log across the redirect. */
|
|
sessionStorageAdapter: RemoteInspectorClientStorage;
|
|
onBeforeOAuthRedirect: (authorizationUrl: URL) => void;
|
|
prepareOAuthRedirect: (args: PrepareOAuthRedirectArgs) => void;
|
|
reAuthBanner: ReAuthBannerState | null;
|
|
setReAuthBanner: (next: ReAuthBannerState | null) => void;
|
|
/**
|
|
* Drops the banner and both pending-OAuth slots. Called from the session
|
|
* reset, which runs on every disconnect: an unanswered step-up prompt or a
|
|
* deferred recovery belongs to the session that raised it.
|
|
*/
|
|
resetOAuthRecoveryState: () => void;
|
|
pendingStepUp: PendingStepUp | null;
|
|
handleStepUpAuthorize: () => Promise<void>;
|
|
handleStepUpCancel: () => void;
|
|
handleCommandScopedAuthRecovery: (
|
|
error: AuthRecoveryRequiredError,
|
|
options: {
|
|
serverId: string;
|
|
source: StepUpSource;
|
|
retryOperation?: () => Promise<unknown>;
|
|
},
|
|
) => Promise<boolean>;
|
|
runWithCommandAuthRecovery: <T>(
|
|
operation: () => Promise<T>,
|
|
source: StepUpSource,
|
|
) => Promise<T | undefined>;
|
|
runCommandInBackground: (
|
|
operation: () => Promise<unknown>,
|
|
source: StepUpSource,
|
|
errorTitle?: string,
|
|
) => void;
|
|
/** OAuth details for the Connection Info modal, live only while connected. */
|
|
connectionInfoOAuth: OAuthDetails | undefined;
|
|
clearServerOAuthAndDisconnect: (server: ClearableServer) => Promise<void>;
|
|
/** Snapshot cleanup plus shell reset when the user explicitly ends a session. */
|
|
finalizeExplicitDisconnect: () => void;
|
|
}
|
|
|
|
/**
|
|
* Everything the web client does to notice that a session's authorization has
|
|
* lapsed and to get it back — the largest single cluster lifted out of
|
|
* `App.tsx` by the phase-2 decomposition (#2153, under #2129/#2126).
|
|
*
|
|
* The cluster is one hook rather than several because its parts share mutable
|
|
* state that has no meaning apart from them: the step-up slot and its retry
|
|
* operation, the deferred-recovery slot, the re-auth banner, and five
|
|
* once-only latches. Splitting it would mean handing those refs across hook
|
|
* boundaries, which is the coupling the extraction exists to remove.
|
|
*
|
|
* **The latches guard real re-entrancy and are not simplifiable.**
|
|
* `oauthCallbackHandledRef` keeps the single-use authorization code to one
|
|
* exchange across the renders the effect needs to wait for `servers` to
|
|
* hydrate; `staleOAuthCheckedRef` keeps the abandoned-redirect check to one
|
|
* run per load; `oauthResumeUiAppliedRef` keeps the restored shell from being
|
|
* re-applied over the user's own navigation; `reauthResumeInProgressRef` and
|
|
* `stepUpAuthorizeInProgressRef` keep two concurrent triggers (a tab-visible
|
|
* event and a reconnect, a double-click) from starting the same authorization
|
|
* twice. Several of them are also what makes StrictMode's effect replay a
|
|
* no-op rather than a second token exchange.
|
|
*
|
|
* Direction is one-way: this hook reads the session through `sessionRef` and
|
|
* calls back into `App.tsx` only through the callbacks it is handed. It never
|
|
* drives a connect — the connect path consumes `prepareOAuthRedirect`,
|
|
* `onBeforeOAuthRedirect` and `sessionStorageAdapter` from here, not the
|
|
* reverse.
|
|
*/
|
|
export function useOAuthRecovery({
|
|
sessionRef,
|
|
servers,
|
|
activeServerId,
|
|
inspectorClient,
|
|
connectionStatus,
|
|
activeTab,
|
|
ui,
|
|
setUi,
|
|
setActiveTab,
|
|
fetchLogRef,
|
|
connectStartRef,
|
|
initialConfigSettledRef,
|
|
setupClientForServerRef,
|
|
setActiveServerId,
|
|
setFailedServerId,
|
|
clearResultPanels,
|
|
setSourceScopedError,
|
|
}: UseOAuthRecoveryOptions): OAuthRecovery {
|
|
const [pendingStepUp, setPendingStepUp] = useState<PendingStepUp | null>(
|
|
null,
|
|
);
|
|
const pendingStepUpRetryRef = useRef<(() => Promise<unknown>) | null>(null);
|
|
const [reAuthBanner, setReAuthBanner] = useState<ReAuthBannerState | null>(
|
|
null,
|
|
);
|
|
const [pendingReauth, setPendingReauth] = useState<PendingReauth | null>(
|
|
null,
|
|
);
|
|
const reauthResumeInProgressRef = useRef(false);
|
|
const stepUpAuthorizeInProgressRef = useRef(false);
|
|
// One-shot guard for the `/oauth/callback` handler below. The effect waits
|
|
// for the async `servers` list to hydrate, so it can run on more than one
|
|
// render; this ref ensures the token exchange fires exactly once per load.
|
|
const oauthCallbackHandledRef = useRef(false);
|
|
const staleOAuthCheckedRef = useRef(false);
|
|
/** Guards against applying the same OAuth resume snapshot more than once per load. */
|
|
const oauthResumeUiAppliedRef = useRef(false);
|
|
|
|
// This hook owns the two pending-OAuth slots, so it is also what mirrors
|
|
// them into the session ref (#2153) — `useSessionRef` deliberately does not
|
|
// take them. Declared first so every effect below reads the current pair,
|
|
// which is the order that held when App.tsx wrote them.
|
|
useEffect(() => {
|
|
sessionRef.current.pendingStepUp = pendingStepUp;
|
|
sessionRef.current.pendingReauth = pendingReauth;
|
|
});
|
|
|
|
useEffect(() => {
|
|
const pending = sessionRef.current.pendingReauth;
|
|
if (pending && pending.serverId !== activeServerId) {
|
|
setPendingReauth(null);
|
|
}
|
|
const stepUp = sessionRef.current.pendingStepUp;
|
|
if (stepUp && stepUp.serverId !== activeServerId) {
|
|
setPendingStepUp(null);
|
|
}
|
|
}, [sessionRef, activeServerId]);
|
|
|
|
const trySetPendingStepUp = useCallback(
|
|
(next: PendingStepUp): boolean => {
|
|
if (sessionRef.current.pendingStepUp !== null) {
|
|
notifications.show({
|
|
title: "Step-up authorization in progress",
|
|
message:
|
|
"Complete or cancel the current step-up prompt before starting another.",
|
|
color: "yellow",
|
|
autoClose: 5000,
|
|
});
|
|
return false;
|
|
}
|
|
setPendingStepUp(next);
|
|
return true;
|
|
},
|
|
[sessionRef],
|
|
);
|
|
|
|
const showReAuthBanner = useCallback(
|
|
(
|
|
serverId: string,
|
|
detail?: unknown,
|
|
options?: { reason?: AuthChallengeReason },
|
|
) => {
|
|
const server = sessionRef.current.servers.find((s) => s.id === serverId);
|
|
const message = reAuthBannerMessage({
|
|
serverName: server?.name,
|
|
detail:
|
|
detail !== undefined ? formatOAuthFailureDetail(detail) : undefined,
|
|
});
|
|
const reason = options?.reason;
|
|
if (reason !== undefined && !isReAuthBannerReason(reason)) {
|
|
notifications.show({
|
|
title: "Authorization required",
|
|
message,
|
|
color: "yellow",
|
|
autoClose: false,
|
|
});
|
|
return;
|
|
}
|
|
setReAuthBanner({
|
|
serverId,
|
|
message,
|
|
});
|
|
},
|
|
[sessionRef],
|
|
);
|
|
|
|
/** Clears pending OAuth resume state — explicit user disconnect only. */
|
|
const clearOAuthResumeOnExplicitDisconnect = useCallback(() => {
|
|
clearOAuthResumeSnapshot();
|
|
oauthResumeUiAppliedRef.current = false;
|
|
}, []);
|
|
|
|
/** Snapshot cleanup plus shell reset when the user explicitly ends a session. */
|
|
const finalizeExplicitDisconnect = useCallback(() => {
|
|
clearOAuthResumeOnExplicitDisconnect();
|
|
setActiveTab(INSPECTOR_SERVERS_TAB);
|
|
}, [clearOAuthResumeOnExplicitDisconnect, setActiveTab]);
|
|
|
|
const [
|
|
connectionInfoOAuthWhenConnected,
|
|
setConnectionInfoOAuthWhenConnected,
|
|
] = useState<OAuthDetails | undefined>(undefined);
|
|
|
|
const connectionInfoOAuth =
|
|
connectionStatus === "connected" && inspectorClient
|
|
? connectionInfoOAuthWhenConnected
|
|
: undefined;
|
|
|
|
useEffect(() => {
|
|
if (connectionStatus !== "connected" || !inspectorClient) {
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
|
|
const refresh = (): void => {
|
|
void inspectorClient.getOAuthState().then((state) => {
|
|
if (cancelled) return;
|
|
setConnectionInfoOAuthWhenConnected(
|
|
state ? oauthDetailsFromConnectionState(state) : undefined,
|
|
);
|
|
});
|
|
};
|
|
|
|
const onAmbientAuthChallenge = (): void => {
|
|
const name = sessionRef.current.activeServerName;
|
|
notifications.show({
|
|
title: name
|
|
? `Refreshing authorization for "${name}"`
|
|
: "Refreshing authorization",
|
|
message: "Refreshing authorization…",
|
|
color: "blue",
|
|
autoClose: 4000,
|
|
});
|
|
};
|
|
|
|
refresh();
|
|
inspectorClient.addEventListener("oauthComplete", refresh);
|
|
inspectorClient.addEventListener(
|
|
"authChallengeAmbient",
|
|
onAmbientAuthChallenge,
|
|
);
|
|
return () => {
|
|
cancelled = true;
|
|
inspectorClient.removeEventListener("oauthComplete", refresh);
|
|
inspectorClient.removeEventListener(
|
|
"authChallengeAmbient",
|
|
onAmbientAuthChallenge,
|
|
);
|
|
};
|
|
}, [sessionRef, connectionStatus, inspectorClient]);
|
|
|
|
// Shared OAuth runtime store (oauth.json via /api/storage/oauth). Memoized so
|
|
// connect, EMA IdP session, and per-server clear share one in-memory view.
|
|
const webOAuthStorage = useMemo(
|
|
() => getWebRemoteOAuthStorage(getAuthToken()),
|
|
[],
|
|
);
|
|
|
|
// Backend-backed session storage used to carry the fetch (Network) log
|
|
// across the OAuth full-page redirect. The auth handshake's first half —
|
|
// protected-resource + auth-server discovery and Dynamic Client
|
|
// Registration — happens on the pre-redirect page; without persisting it
|
|
// those `auth` entries would vanish when the browser navigates to the
|
|
// authorization server. `FetchRequestLogState` saves to this on the
|
|
// client's `saveSession` event (fired in `onBeforeOAuthRedirect`) keyed by
|
|
// the OAuth authId, and restores from it when rebuilt on `/oauth/callback`.
|
|
// Created once; `getAuthToken()` is stable for the page's lifetime.
|
|
const sessionStorageAdapter = useMemo(
|
|
() =>
|
|
new RemoteInspectorClientStorage({
|
|
baseUrl:
|
|
typeof window !== "undefined"
|
|
? window.location.origin
|
|
: "http://localhost",
|
|
authToken: getAuthToken(),
|
|
}),
|
|
[],
|
|
);
|
|
|
|
// Flush the pre-redirect Network log to backend storage, keyed by the OAuth
|
|
// authId carried in the authorization URL's `state`. Runs synchronously from
|
|
// `BrowserNavigation` right before `window.location.href`, so the keepalive
|
|
// POST it kicks off outlives the unloading page. The `/oauth/callback`
|
|
// rebuild restores these entries via `FetchRequestLogState`'s `sessionId`.
|
|
// Stable identity: it reads mutable refs, so it never needs to be rebuilt.
|
|
const onBeforeOAuthRedirect = useCallback(
|
|
(authorizationUrl: URL) => {
|
|
const stateParam = authorizationUrl.searchParams.get("state");
|
|
const authId = stateParam
|
|
? (parseOAuthState(stateParam)?.authId ?? undefined)
|
|
: undefined;
|
|
if (!authId) return;
|
|
const fetchRequests = fetchLogRef.current?.getFetchRequests() ?? [];
|
|
if (fetchRequests.length === 0) return;
|
|
const now = Date.now();
|
|
// Fire-and-forget: the keepalive request inside `saveSession` is
|
|
// dispatched synchronously here, before navigation commits.
|
|
void sessionStorageAdapter
|
|
.saveSession(authId, {
|
|
fetchRequests,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.catch(() => {
|
|
// Best-effort; losing the pre-redirect log is non-fatal.
|
|
});
|
|
},
|
|
[sessionStorageAdapter, fetchLogRef],
|
|
);
|
|
|
|
const prepareOAuthRedirect = useCallback(
|
|
({
|
|
serverId,
|
|
authKind,
|
|
authorizationUrl,
|
|
authChallenge,
|
|
recoverySource,
|
|
preRedirectContext,
|
|
client,
|
|
}: PrepareOAuthRedirectArgs) => {
|
|
setReAuthBanner(null);
|
|
const server = sessionRef.current.servers.find((s) => s.id === serverId);
|
|
const preRedirectToast = oauthPreRedirectToastCopy(authKind, {
|
|
serverName: server?.name,
|
|
enterpriseManaged: server?.settings?.enterpriseManaged,
|
|
context: preRedirectContext,
|
|
});
|
|
if (preRedirectToast) {
|
|
notifications.show({
|
|
title: preRedirectToast.title,
|
|
message: preRedirectToast.message,
|
|
color: "blue",
|
|
autoClose: 4000,
|
|
});
|
|
}
|
|
const oauthClient = client ?? inspectorClient;
|
|
const remoteSessionId = oauthClient?.getRemoteBackendSessionId();
|
|
onBeforeOAuthRedirect(authorizationUrl);
|
|
// Write immediately before navigation so implicit client teardown (during
|
|
// connect setup) cannot clear the snapshot after we persist it.
|
|
writeOAuthResumeSnapshot({
|
|
version: 1,
|
|
serverId,
|
|
activeTab,
|
|
authKind,
|
|
tabUi: buildTabUiSnapshot(ui),
|
|
...(remoteSessionId && { remoteSessionId }),
|
|
...(authKind === "step_up" && authChallenge && { authChallenge }),
|
|
...(recoverySource && { recoverySource }),
|
|
});
|
|
void oauthClient?.beginInteractiveAuthorization(authorizationUrl);
|
|
},
|
|
[sessionRef, inspectorClient, activeTab, ui, onBeforeOAuthRedirect],
|
|
);
|
|
|
|
const tryApplyStoredAuthRecovery = useCallback(
|
|
async (
|
|
client: InspectorClient,
|
|
challenge: AuthChallenge,
|
|
recoverySource?: OAuthRecoverySource,
|
|
): Promise<boolean> => {
|
|
if (!(await client.checkAuthChallengeSatisfied(challenge))) {
|
|
return false;
|
|
}
|
|
await client.pushRemoteAuthState();
|
|
notifications.show({
|
|
title: "Authorization restored",
|
|
message: authRecoveryRestoredMessage({ recoverySource }),
|
|
color: "green",
|
|
autoClose: 4000,
|
|
});
|
|
return true;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const runVisibleInteractiveAuth = useCallback(
|
|
({
|
|
serverId,
|
|
challenge,
|
|
authorizationUrl,
|
|
source = "ambient",
|
|
}: {
|
|
serverId: string;
|
|
challenge: AuthChallenge;
|
|
authorizationUrl: URL;
|
|
source?: StepUpSource;
|
|
}) => {
|
|
const server = sessionRef.current.servers.find((s) => s.id === serverId);
|
|
if (isStepUpConfirmation(challenge, server)) {
|
|
trySetPendingStepUp({
|
|
challenge,
|
|
authorizationUrl,
|
|
serverId,
|
|
source,
|
|
enterpriseManaged: isEmaStepUp(challenge, server),
|
|
});
|
|
return;
|
|
}
|
|
prepareOAuthRedirect({
|
|
serverId,
|
|
authKind: "reauth",
|
|
authorizationUrl,
|
|
recoverySource: source,
|
|
});
|
|
},
|
|
[sessionRef, prepareOAuthRedirect, trySetPendingStepUp],
|
|
);
|
|
|
|
const deferAmbientReauth = useCallback(
|
|
(pending: PendingReauth) => {
|
|
if (sessionRef.current.pendingReauth) {
|
|
notifications.show({
|
|
title: "Authorization update pending",
|
|
message:
|
|
"A new authorization request replaced the previous deferred recovery.",
|
|
color: "yellow",
|
|
autoClose: 5000,
|
|
});
|
|
} else {
|
|
notifications.show({
|
|
title: "Authorization pending",
|
|
message: "Return to this tab to continue authorization.",
|
|
color: "blue",
|
|
autoClose: 5000,
|
|
});
|
|
}
|
|
setPendingReauth(pending);
|
|
},
|
|
[sessionRef],
|
|
);
|
|
|
|
const handleCommandScopedAuthRecovery = useCallback(
|
|
async (
|
|
error: AuthRecoveryRequiredError,
|
|
options: {
|
|
serverId: string;
|
|
source: StepUpSource;
|
|
retryOperation?: () => Promise<unknown>;
|
|
},
|
|
): Promise<boolean> => {
|
|
if (!inspectorClient) {
|
|
return false;
|
|
}
|
|
const server = sessionRef.current.servers.find(
|
|
(s) => s.id === options.serverId,
|
|
);
|
|
const stepUp =
|
|
error.emaStepUpConfirm ||
|
|
isStepUpConfirmation(error.authChallenge, server);
|
|
|
|
if (stepUp) {
|
|
if (
|
|
await tryApplyStoredAuthRecovery(
|
|
inspectorClient,
|
|
error.authChallenge,
|
|
options.source,
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
pendingStepUpRetryRef.current = options.retryOperation ?? null;
|
|
trySetPendingStepUp({
|
|
challenge: error.authChallenge,
|
|
authorizationUrl: error.authorizationUrl,
|
|
serverId: options.serverId,
|
|
source: options.source,
|
|
enterpriseManaged: isEmaStepUp(error.authChallenge, server),
|
|
});
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
await tryApplyStoredAuthRecovery(
|
|
inspectorClient,
|
|
error.authChallenge,
|
|
options.source,
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
prepareOAuthRedirect({
|
|
serverId: options.serverId,
|
|
authKind: "reauth",
|
|
authorizationUrl: error.authorizationUrl,
|
|
recoverySource: options.source,
|
|
});
|
|
return false;
|
|
},
|
|
[
|
|
sessionRef,
|
|
inspectorClient,
|
|
prepareOAuthRedirect,
|
|
tryApplyStoredAuthRecovery,
|
|
trySetPendingStepUp,
|
|
],
|
|
);
|
|
|
|
const runWithCommandAuthRecovery = useCallback(
|
|
async <T>(
|
|
operation: () => Promise<T>,
|
|
source: StepUpSource,
|
|
): Promise<T | undefined> => {
|
|
if (!inspectorClient || !activeServerId) {
|
|
return operation();
|
|
}
|
|
try {
|
|
return await operation();
|
|
} catch (err) {
|
|
if (err instanceof AuthRecoveryRequiredError) {
|
|
const satisfied = await handleCommandScopedAuthRecovery(err, {
|
|
serverId: activeServerId,
|
|
source,
|
|
retryOperation: operation,
|
|
});
|
|
if (satisfied) {
|
|
return operation();
|
|
}
|
|
return undefined;
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
[inspectorClient, activeServerId, handleCommandScopedAuthRecovery],
|
|
);
|
|
|
|
/**
|
|
* Fire-and-forget form of {@link runWithCommandAuthRecovery}, for a command
|
|
* whose caller has nothing to await (a click handler, a mode toggle).
|
|
*
|
|
* The wrapper rethrows anything that is not an `AuthRecoveryRequiredError`,
|
|
* so a bare `void runWithCommandAuthRecovery(...)` turns every non-auth
|
|
* failure — a transport error, a rejected `tools/list` — into an unhandled
|
|
* rejection in the browser (#2049). Routing every background command through
|
|
* here is what keeps a new call site from reintroducing that gap by
|
|
* omission: there is no `void` to forget.
|
|
*
|
|
* `errorTitle` picks the reporting, and the choice is per call site:
|
|
*
|
|
* - **Omit it** when the operation's own state already renders the failure —
|
|
* the list loads, whose managed/paged stores record the error and whose
|
|
* panel shows it with a Retry. A toast there would only duplicate what the
|
|
* user is already looking at, so the rejection is swallowed deliberately.
|
|
* - **Pass one** when nothing else records the failure, and the user would
|
|
* otherwise see the command silently do nothing.
|
|
*/
|
|
const runCommandInBackground = useCallback(
|
|
(
|
|
operation: () => Promise<unknown>,
|
|
source: StepUpSource,
|
|
errorTitle?: string,
|
|
): void => {
|
|
void runWithCommandAuthRecovery(operation, source).catch(
|
|
(err: unknown) => {
|
|
if (!errorTitle) return;
|
|
notifications.show({
|
|
title: errorTitle,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
color: "red",
|
|
});
|
|
},
|
|
);
|
|
},
|
|
[runWithCommandAuthRecovery],
|
|
);
|
|
|
|
const resumePendingReauth = useCallback(
|
|
async (pending: PendingReauth) => {
|
|
if (reauthResumeInProgressRef.current) {
|
|
return;
|
|
}
|
|
const client = inspectorClient;
|
|
if (!client) {
|
|
return;
|
|
}
|
|
if (connectionStatus !== "connected") {
|
|
return;
|
|
}
|
|
if (pending.serverId !== sessionRef.current.activeServerId) {
|
|
return;
|
|
}
|
|
|
|
reauthResumeInProgressRef.current = true;
|
|
setPendingReauth(null);
|
|
try {
|
|
if (
|
|
await tryApplyStoredAuthRecovery(
|
|
client,
|
|
pending.challenge,
|
|
pending.source,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (pending.authKind === "step_up") {
|
|
runVisibleInteractiveAuth({
|
|
serverId: pending.serverId,
|
|
challenge: pending.challenge,
|
|
authorizationUrl: pending.authorizationUrl,
|
|
source: pending.source,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const outcome = await client.handleAuthChallenge(pending.challenge);
|
|
if (outcome.kind === "satisfied") {
|
|
await client.pushRemoteAuthState();
|
|
notifications.show({
|
|
title: "Authorization restored",
|
|
message: authRecoveryRestoredMessage({
|
|
recoverySource: pending.source,
|
|
}),
|
|
color: "green",
|
|
autoClose: 4000,
|
|
});
|
|
return;
|
|
}
|
|
if (outcome.kind === "interactive") {
|
|
runVisibleInteractiveAuth({
|
|
serverId: pending.serverId,
|
|
challenge: outcome.challenge,
|
|
authorizationUrl: outcome.authorizationUrl,
|
|
source: pending.source,
|
|
});
|
|
return;
|
|
}
|
|
if (outcome.kind === "failed") {
|
|
showReAuthBanner(pending.serverId, outcome.error, {
|
|
reason: pending.challenge.reason,
|
|
});
|
|
}
|
|
} finally {
|
|
reauthResumeInProgressRef.current = false;
|
|
}
|
|
},
|
|
[
|
|
sessionRef,
|
|
inspectorClient,
|
|
connectionStatus,
|
|
tryApplyStoredAuthRecovery,
|
|
runVisibleInteractiveAuth,
|
|
showReAuthBanner,
|
|
],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (connectionStatus !== "connected" || !inspectorClient) {
|
|
return;
|
|
}
|
|
|
|
const onAuthChallengeInteractive = (
|
|
event: TypedEvent<"authChallengeInteractive">,
|
|
): void => {
|
|
void (async () => {
|
|
const { challenge, authorizationUrl } = event.detail;
|
|
const serverId = sessionRef.current.activeServerId;
|
|
if (!serverId) {
|
|
return;
|
|
}
|
|
const server = sessionRef.current.servers.find(
|
|
(s) => s.id === serverId,
|
|
);
|
|
const authKind: OAuthResumeAuthKind = isStepUpConfirmation(
|
|
challenge,
|
|
server,
|
|
)
|
|
? "step_up"
|
|
: "reauth";
|
|
|
|
if (!isBrowserTabVisible()) {
|
|
deferAmbientReauth({
|
|
serverId,
|
|
challenge,
|
|
authorizationUrl,
|
|
authKind,
|
|
source: "ambient",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (await tryApplyStoredAuthRecovery(inspectorClient, challenge)) {
|
|
return;
|
|
}
|
|
|
|
runVisibleInteractiveAuth({
|
|
serverId,
|
|
challenge,
|
|
authorizationUrl,
|
|
source: "ambient",
|
|
});
|
|
})();
|
|
};
|
|
|
|
inspectorClient.addEventListener(
|
|
"authChallengeInteractive",
|
|
onAuthChallengeInteractive,
|
|
);
|
|
return () => {
|
|
inspectorClient.removeEventListener(
|
|
"authChallengeInteractive",
|
|
onAuthChallengeInteractive,
|
|
);
|
|
};
|
|
}, [
|
|
sessionRef,
|
|
connectionStatus,
|
|
inspectorClient,
|
|
tryApplyStoredAuthRecovery,
|
|
runVisibleInteractiveAuth,
|
|
deferAmbientReauth,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
return onBrowserTabVisible(() => {
|
|
const pending = sessionRef.current.pendingReauth;
|
|
if (pending) {
|
|
void resumePendingReauth(pending);
|
|
}
|
|
});
|
|
}, [sessionRef, resumePendingReauth]);
|
|
|
|
// Resume deferred background-tab recovery once the session reconnects.
|
|
useEffect(() => {
|
|
if (connectionStatus !== "connected" || !inspectorClient) {
|
|
return;
|
|
}
|
|
if (!isBrowserTabVisible()) {
|
|
return;
|
|
}
|
|
const pending = sessionRef.current.pendingReauth;
|
|
if (pending) {
|
|
void resumePendingReauth(pending);
|
|
}
|
|
}, [sessionRef, connectionStatus, inspectorClient, resumePendingReauth]);
|
|
|
|
useEffect(() => {
|
|
if (connectionStatus !== "connected" || !inspectorClient) {
|
|
return;
|
|
}
|
|
|
|
const onOAuthError = (event: TypedEvent<"oauthError">): void => {
|
|
const serverId = sessionRef.current.activeServerId;
|
|
if (!serverId) {
|
|
return;
|
|
}
|
|
showReAuthBanner(serverId, event.detail.error);
|
|
};
|
|
|
|
inspectorClient.addEventListener("oauthError", onOAuthError);
|
|
return () => {
|
|
inspectorClient.removeEventListener("oauthError", onOAuthError);
|
|
};
|
|
}, [sessionRef, connectionStatus, inspectorClient, showReAuthBanner]);
|
|
|
|
// Detect an abandoned full-page OAuth redirect (snapshot left, no callback).
|
|
useEffect(() => {
|
|
if (staleOAuthCheckedRef.current) return;
|
|
if (typeof window === "undefined") return;
|
|
if (window.location.pathname === OAUTH_CALLBACK_PATH) return;
|
|
if (servers.length === 0) return;
|
|
staleOAuthCheckedRef.current = true;
|
|
|
|
const snapshot = consumeOAuthResumeSnapshot();
|
|
if (!snapshot) {
|
|
return;
|
|
}
|
|
queueMicrotask(() => {
|
|
if (snapshot.authKind === "reauth") {
|
|
showReAuthBanner(
|
|
snapshot.serverId,
|
|
oauthResumeAbandonedMessage(snapshot.authKind),
|
|
);
|
|
return;
|
|
}
|
|
if (snapshot.authKind === "step_up") {
|
|
showReAuthBanner(
|
|
snapshot.serverId,
|
|
oauthResumeAbandonedMessage(snapshot.authKind, {
|
|
recoverySource: snapshot.recoverySource,
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
}, [servers, showReAuthBanner]);
|
|
|
|
// Finish the OAuth authorization-code flow when the auth server redirects
|
|
// back to `/oauth/callback`. This runs on a fresh page load (the redirect in
|
|
// `onToggleConnection` unloaded the previous one), so all React state is
|
|
// reset and we recover the initiating server from sessionStorage. We wait for
|
|
// `servers` to hydrate before acting; the ref guard keeps the exchange to a
|
|
// single run. The persisted PKCE verifier + DCR client info live in shared
|
|
// `RemoteOAuthStorage` (`oauth.json`) and survive the redirect, so
|
|
// `completeOAuthFlow` exchanges the code without needing the original
|
|
// in-memory state machine.
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
if (window.location.pathname !== OAUTH_CALLBACK_PATH) return;
|
|
if (oauthCallbackHandledRef.current) return;
|
|
// `useServers` returns [] until the first fetch resolves; defer until the
|
|
// list is populated so `find` can resolve the pending server.
|
|
if (servers.length === 0) return;
|
|
// The connect path builds the client, and it is declared after this hook
|
|
// runs. Reading it here rather than latching first means a render that
|
|
// somehow arrives with a hydrated list before the assignment simply tries
|
|
// again on the next one, instead of burning the one-shot latch on a run
|
|
// that could not have exchanged anything.
|
|
const setupClientForServer = setupClientForServerRef.current;
|
|
if (!setupClientForServer) return;
|
|
oauthCallbackHandledRef.current = true;
|
|
|
|
const params = parseOAuthCallbackParams(window.location.search);
|
|
// The OAuth `state` round-trips the auth session id; the authId is the
|
|
// session key the pre-redirect page saved the fetch log under, so the
|
|
// rebuilt client can restore those `auth` entries. Read it before the
|
|
// URL is cleared below.
|
|
//
|
|
// Defense-in-depth: a `state` that is present but does not parse to our
|
|
// expected 64-char-hex authId shape did not originate from
|
|
// `generateOAuthState`, so reject the callback instead of silently
|
|
// proceeding with an undefined sessionId. This is a shape check, not full
|
|
// state-matching — the primary CSRF protection remains PKCE
|
|
// (`code_verifier`); this layer catches malformed/forged `state` early.
|
|
//
|
|
// Intentional asymmetry: a present-but-malformed `state` (including the
|
|
// empty string `?state=`) is rejected, but a wholly absent `state`
|
|
// (`stateParam === null`) is *not* — it falls through with
|
|
// `sessionId = undefined` and is matched via the OAuth resume snapshot
|
|
// (`resumeSnapshot.serverId`) instead. Rejecting the null case would turn any provider error redirect
|
|
// that omits `state` into a misleading "rejected" toast, hiding the real
|
|
// OAuth error surfaced by the `!params.successful` branch below.
|
|
const stateParam = new URLSearchParams(window.location.search).get("state");
|
|
const parsedState = stateParam ? parseOAuthState(stateParam) : null;
|
|
const stateRejected = stateParam !== null && parsedState === null;
|
|
const sessionId = parsedState?.authId ?? undefined;
|
|
const resumeSnapshot = consumeOAuthResumeSnapshot();
|
|
|
|
// Strip the code/state off the URL immediately so a reload can't replay
|
|
// the (now single-use) authorization code through the exchange again.
|
|
window.history.replaceState({}, "", "/");
|
|
|
|
if (stateRejected) {
|
|
notifications.show({
|
|
title: "OAuth callback rejected",
|
|
message:
|
|
"OAuth callback carried an unrecognized state parameter that did not originate from this session. Please try connecting again.",
|
|
color: "red",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const applyResumeUiOnce = (
|
|
snapshot: NonNullable<typeof resumeSnapshot>,
|
|
) => {
|
|
if (oauthResumeUiAppliedRef.current) {
|
|
return;
|
|
}
|
|
applyOAuthResumeUi(snapshot, {
|
|
...setUi,
|
|
setActiveTab,
|
|
// One callback behind all three: `applyOAuthResumeUi` calls them
|
|
// unconditionally and in sequence, so clearing every in-flight result
|
|
// panel per call is the same end state as clearing one each.
|
|
clearToolCallState: clearResultPanels,
|
|
clearGetPromptState: clearResultPanels,
|
|
clearReadResourceState: clearResultPanels,
|
|
});
|
|
oauthResumeUiAppliedRef.current = true;
|
|
};
|
|
|
|
if (resumeSnapshot && params.successful) {
|
|
applyResumeUiOnce(resumeSnapshot);
|
|
}
|
|
|
|
if (!params.successful) {
|
|
const pendingId = resumeSnapshot?.serverId;
|
|
if (pendingId) {
|
|
// Red border only (#1621), not a sidebar. This arm returns before a
|
|
// client is rebuilt, so the persisted `auth` entries are never
|
|
// restored and the content-gated column stays shut — correctly: the
|
|
// provider's own `error` param is the whole diagnostic, and the
|
|
// re-auth banner below is already showing it. The flag is still right,
|
|
// because the attempt did fail.
|
|
setFailedServerId(pendingId);
|
|
queueMicrotask(() => {
|
|
showReAuthBanner(pendingId, generateOAuthErrorDescription(params));
|
|
});
|
|
} else {
|
|
notifications.show({
|
|
title: "OAuth authorization failed",
|
|
message: generateOAuthErrorDescription(params),
|
|
color: "red",
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const pendingId = resumeSnapshot?.serverId;
|
|
const server = pendingId
|
|
? servers.find((s) => s.id === pendingId)
|
|
: undefined;
|
|
if (!server) {
|
|
notifications.show({
|
|
title: "OAuth callback could not be matched",
|
|
message:
|
|
"Could not determine which server started the OAuth flow. Please try connecting again.",
|
|
color: "red",
|
|
});
|
|
return;
|
|
}
|
|
|
|
void (async () => {
|
|
// Same reason as the connect path: whether this client may advertise
|
|
// app-rendered elicitation is fixed at construction.
|
|
await initialConfigSettledRef.current?.promise;
|
|
try {
|
|
await webOAuthStorage.load();
|
|
} catch (err) {
|
|
connectStartRef.current = undefined;
|
|
// Red border only, for the same reason as the provider-error arm above
|
|
// — and here the network log would not help anyway: this is a failure
|
|
// to read local OAuth storage, not a request that went out.
|
|
setFailedServerId(server.id);
|
|
queueMicrotask(() => {
|
|
showReAuthBanner(server.id, err instanceof Error ? err : String(err));
|
|
});
|
|
return;
|
|
}
|
|
const client = setupClientForServer(server, sessionId);
|
|
setActiveServerId(server.id);
|
|
try {
|
|
connectStartRef.current = Date.now();
|
|
await client.resumeAfterOAuth(params.code, {
|
|
remoteSessionId: resumeSnapshot?.remoteSessionId,
|
|
iss: params.iss,
|
|
});
|
|
} catch (err) {
|
|
connectStartRef.current = undefined;
|
|
// `resumeAfterOAuth` carries the reconnect, and that reconnect can
|
|
// reject with an auth-recovery error — which holds the status at
|
|
// `"connecting"` instead of moving it to `"error"`. Nothing downstream
|
|
// of here ends the attempt, so without this the toggle is stuck and the
|
|
// active-server lock is never released. Before the EMA guard, since a
|
|
// stuck session is worth clearing whichever way the error classifies.
|
|
await client.disconnect().catch(() => {});
|
|
// `activeServerId` is deliberately NOT cleared here, even though the
|
|
// disconnect above often cannot announce itself: it emits only on a
|
|
// status *change*, and the commonest failure — a rejected token
|
|
// exchange — throws inside `completeOAuthFlow` before the reconnect
|
|
// runs, so this freshly built client is still at its initial
|
|
// `"disconnected"` and the listener that would clear it never fires.
|
|
//
|
|
// That looks like a leak and is not. The next step after a callback
|
|
// failure is the re-auth banner below, and its "Authorize again" hands
|
|
// `clearServerOAuthState` the live client only when the banner's server
|
|
// *is* the active one. Releasing it here would pass `null` instead, and
|
|
// the stale tokens would never be cleared from the client that holds
|
|
// them — the one thing that recovery exists to do.
|
|
if (isEmaClientNotConfiguredError(err)) {
|
|
notifications.show({
|
|
title: `Cannot connect to "${server.name}"`,
|
|
message: err.message,
|
|
color: "red",
|
|
autoClose: false,
|
|
});
|
|
return;
|
|
}
|
|
// The token exchange (or the re-handshake behind it) failed. Flag the
|
|
// server (#1621) so the monitoring sidebar opens onto the OAuth
|
|
// requests that explain it (#2108) — the rebuilt client restored the
|
|
// pre-redirect `auth` fetch entries from the session, so discovery,
|
|
// DCR and the token exchange are all there.
|
|
//
|
|
// Below the EMA guard, not above it: an unconfigured enterprise client
|
|
// is a *configuration* error rather than a failed attempt, and both
|
|
// connect-path arms already return on it without flagging. Flagging it
|
|
// only here would make the three disagree about what the red border
|
|
// means. Above every other arm, so the classification fan-out that
|
|
// follows carries it whichever way it goes.
|
|
setFailedServerId(server.id);
|
|
// SEP-2352 issuer binding (#1808). Two very different failures share
|
|
// one SDK error class, so classify before falling through to the
|
|
// generic re-auth banner (whose detail line would otherwise be the raw
|
|
// "AuthorizationServerMismatchError" text):
|
|
// - no discovery state was recorded → recoverable bookkeeping loss,
|
|
// surface the "Authorize again" affordance;
|
|
// - a *different* issuer answered the callback → security signal, so
|
|
// no one-click recovery is offered.
|
|
const issuerBindingFailure = findIssuerBindingFailure(err);
|
|
if (issuerBindingFailure) {
|
|
const copy = issuerBindingFailureCopy(issuerBindingFailure, {
|
|
serverName: server.name,
|
|
});
|
|
if (issuerBindingFailure.kind === "lost_authorization_state") {
|
|
setReAuthBanner({
|
|
serverId: server.id,
|
|
message: copy.message,
|
|
kind: "lost_authorization_state",
|
|
title: copy.title,
|
|
actionLabel: lostAuthorizationStateActionLabel(),
|
|
});
|
|
} else {
|
|
notifications.show({
|
|
title: copy.title,
|
|
message: copy.message,
|
|
color: "red",
|
|
autoClose: false,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
queueMicrotask(() => {
|
|
showReAuthBanner(server.id, err instanceof Error ? err : String(err));
|
|
});
|
|
return;
|
|
}
|
|
|
|
setReAuthBanner(null);
|
|
|
|
if (resumeSnapshot) {
|
|
const stepUpChallenge =
|
|
resumeSnapshot.authKind === "step_up"
|
|
? resumeSnapshot.authChallenge
|
|
: undefined;
|
|
const stepUpScopesGranted =
|
|
!stepUpChallenge ||
|
|
(await client.checkAuthChallengeSatisfied(stepUpChallenge));
|
|
|
|
if (stepUpChallenge && !stepUpScopesGranted) {
|
|
notifications.show({
|
|
title: "Additional permissions not granted",
|
|
message: oauthResumeInsufficientScopeMessage(stepUpChallenge),
|
|
color: "yellow",
|
|
autoClose: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
notifications.show({
|
|
title: "Authorization complete",
|
|
message: oauthResumeToastMessage(resumeSnapshot.authKind, {
|
|
recoverySource: resumeSnapshot.recoverySource,
|
|
}),
|
|
color: "green",
|
|
autoClose: 6000,
|
|
});
|
|
}
|
|
})();
|
|
}, [
|
|
servers,
|
|
setupClientForServerRef,
|
|
setActiveServerId,
|
|
setFailedServerId,
|
|
connectStartRef,
|
|
initialConfigSettledRef,
|
|
clearResultPanels,
|
|
showReAuthBanner,
|
|
webOAuthStorage,
|
|
setUi,
|
|
setActiveTab,
|
|
]);
|
|
|
|
const clearServerOAuthAndDisconnect = useCallback(
|
|
async (server: ClearableServer) => {
|
|
const isActive = server.id === activeServerId;
|
|
const client = isActive ? inspectorClient : null;
|
|
// The RFC 7009 leg is a bounded network request (#2144), so this callback
|
|
// can stay suspended for seconds — long enough for the user to close the
|
|
// modal and switch servers. `isActive` and `inspectorClient` were
|
|
// snapshotted before it, so everything below would otherwise apply to
|
|
// whatever session is active *now*: disconnecting a client the user just
|
|
// switched to, and running the session-wide UI cleanup against it.
|
|
//
|
|
// The session ref is the live answer. Only the parts that touch the
|
|
// *session* are gated on it; the store write already happened and the
|
|
// toast still belongs to the server the user asked about.
|
|
// The client identity is part of the check, not just the server id: a
|
|
// disconnect/reconnect to the SAME server builds a replacement
|
|
// `InspectorClient`, so an id-only check passes again and the old clear
|
|
// would run its session-wide cleanup against the new session.
|
|
const stillTargetsActiveSession = (): boolean =>
|
|
isActive &&
|
|
sessionRef.current.activeServerId === server.id &&
|
|
sessionRef.current.inspectorClient === client;
|
|
|
|
const { cleared, revocation } = await clearServerOAuthState({
|
|
config: server.config,
|
|
inspectorClient: client,
|
|
isActiveConnection: isActive,
|
|
oauthStorage: webOAuthStorage,
|
|
revoke: server.settings?.oauthRevokeOnClear !== false,
|
|
fetchFn: getWebProxiedFetch(getAuthToken()),
|
|
});
|
|
if (!cleared) return;
|
|
|
|
if (client && stillTargetsActiveSession()) {
|
|
try {
|
|
await client.disconnect();
|
|
} catch (err) {
|
|
// The clear has already succeeded, so this must not propagate: the
|
|
// caller's catch would report "Could not clear the stored OAuth
|
|
// state", which is false. Reported as what it is, and the
|
|
// cleared-successfully toast below still goes out.
|
|
notifications.show({
|
|
title: "Cleared, but the session did not disconnect cleanly",
|
|
message: err instanceof Error ? err.message : String(err),
|
|
color: "yellow",
|
|
});
|
|
} finally {
|
|
// Revalidate after the second await for the same reason.
|
|
if (stillTargetsActiveSession()) {
|
|
setConnectionInfoOAuthWhenConnected(undefined);
|
|
finalizeExplicitDisconnect();
|
|
}
|
|
}
|
|
} else if (!isActive || stillTargetsActiveSession()) {
|
|
// No client to disconnect — either this is a stored-only clear, or the
|
|
// active session has none yet (it is being built or torn down). Either
|
|
// way the resume snapshot is stale and must go; skipping it would leave
|
|
// OAuth recovery state pointing at credentials that no longer exist.
|
|
// Guarded so a clear whose session has moved on still publishes
|
|
// nothing.
|
|
clearOAuthResumeOnExplicitDisconnect();
|
|
}
|
|
|
|
notifications.show({
|
|
title: "OAuth state cleared",
|
|
message: isActive
|
|
? `Stored tokens and client registration were removed. Reconnect to run a fresh authorization flow.${revocationSuffix(revocation)}`
|
|
: `Stored OAuth state was removed for "${server.name}". Connect to authorize again.${revocationSuffix(revocation)}`,
|
|
color: "blue",
|
|
});
|
|
},
|
|
[
|
|
activeServerId,
|
|
inspectorClient,
|
|
sessionRef,
|
|
webOAuthStorage,
|
|
finalizeExplicitDisconnect,
|
|
clearOAuthResumeOnExplicitDisconnect,
|
|
],
|
|
);
|
|
|
|
const handleStepUpAuthorize = async () => {
|
|
if (!pendingStepUp || stepUpAuthorizeInProgressRef.current) {
|
|
return;
|
|
}
|
|
const stepUp = pendingStepUp;
|
|
const client = inspectorClient;
|
|
if (!client) {
|
|
return;
|
|
}
|
|
|
|
if (stepUp.enterpriseManaged) {
|
|
stepUpAuthorizeInProgressRef.current = true;
|
|
setPendingStepUp(null);
|
|
notifications.show({
|
|
title: "Organization permissions",
|
|
message: emaStepUpInProgressMessage(),
|
|
color: "blue",
|
|
autoClose: 4000,
|
|
});
|
|
try {
|
|
const outcome = await client.handleAuthChallenge(stepUp.challenge, {
|
|
confirmedStepUp: true,
|
|
});
|
|
if (outcome.kind === "satisfied") {
|
|
await client.pushRemoteAuthState();
|
|
notifications.show({
|
|
title: "Permissions updated",
|
|
message: emaStepUpSuccessMessage({
|
|
recoverySource: stepUp.source,
|
|
}),
|
|
color: "green",
|
|
autoClose: 5000,
|
|
});
|
|
const retry = pendingStepUpRetryRef.current;
|
|
pendingStepUpRetryRef.current = null;
|
|
if (retry) {
|
|
await retry();
|
|
}
|
|
return;
|
|
}
|
|
if (outcome.kind === "interactive") {
|
|
prepareOAuthRedirect({
|
|
serverId: stepUp.serverId,
|
|
authKind: "step_up",
|
|
authorizationUrl: outcome.authorizationUrl,
|
|
authChallenge: outcome.challenge,
|
|
recoverySource: stepUp.source,
|
|
});
|
|
return;
|
|
}
|
|
if (outcome.kind === "failed") {
|
|
const failureMessage = emaStepUpFailureMessage(outcome.error.message);
|
|
notifications.show({
|
|
title: "Organization permissions",
|
|
message: failureMessage,
|
|
color: "red",
|
|
autoClose: 6000,
|
|
});
|
|
setSourceScopedError(stepUp.source, failureMessage);
|
|
}
|
|
} finally {
|
|
stepUpAuthorizeInProgressRef.current = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
stepUpAuthorizeInProgressRef.current = true;
|
|
prepareOAuthRedirect({
|
|
serverId: stepUp.serverId,
|
|
authKind: "step_up",
|
|
authorizationUrl: stepUp.authorizationUrl,
|
|
authChallenge: stepUp.challenge,
|
|
recoverySource: stepUp.source,
|
|
});
|
|
setPendingStepUp(null);
|
|
pendingStepUpRetryRef.current = null;
|
|
stepUpAuthorizeInProgressRef.current = false;
|
|
};
|
|
|
|
const resetOAuthRecoveryState = useCallback(() => {
|
|
setPendingStepUp(null);
|
|
setPendingReauth(null);
|
|
setReAuthBanner(null);
|
|
}, []);
|
|
|
|
const handleStepUpCancel = () => {
|
|
const stepUp = sessionRef.current.pendingStepUp;
|
|
setPendingStepUp(null);
|
|
pendingStepUpRetryRef.current = null;
|
|
if (!stepUp) {
|
|
return;
|
|
}
|
|
const cancelled = "Authorization cancelled.";
|
|
setSourceScopedError(stepUp.source, cancelled);
|
|
if (stepUp.source === "app") {
|
|
notifications.show({
|
|
title: "Authorization cancelled",
|
|
message: cancelled,
|
|
color: "gray",
|
|
autoClose: 4000,
|
|
});
|
|
}
|
|
};
|
|
|
|
return {
|
|
webOAuthStorage,
|
|
sessionStorageAdapter,
|
|
onBeforeOAuthRedirect,
|
|
prepareOAuthRedirect,
|
|
reAuthBanner,
|
|
setReAuthBanner,
|
|
resetOAuthRecoveryState,
|
|
pendingStepUp,
|
|
handleStepUpAuthorize,
|
|
handleStepUpCancel,
|
|
handleCommandScopedAuthRecovery,
|
|
runWithCommandAuthRecovery,
|
|
runCommandInBackground,
|
|
connectionInfoOAuth,
|
|
clearServerOAuthAndDisconnect,
|
|
finalizeExplicitDisconnect,
|
|
};
|
|
}
|