diff --git a/client/src/App.tsx b/client/src/App.tsx index 39fc2812a..ee03b995d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -31,7 +31,6 @@ import { isReservedMetaKey, } from "@/utils/metaUtils"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; -import { OAuthStateMachine } from "./lib/oauth-state-machine"; import { cacheToolOutputSchemas } from "./utils/schemaUtils"; import { cleanParams } from "./utils/paramUtils"; import type { JsonSchemaType } from "./utils/jsonUtils"; @@ -39,6 +38,7 @@ import React, { Suspense, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; @@ -552,10 +552,9 @@ const App = () => { ); const onOAuthDebugConnect = useCallback( - async ({ + ({ authorizationCode, errorMsg, - restoredState, }: { authorizationCode?: string; errorMsg?: string; @@ -566,62 +565,26 @@ const App = () => { if (errorMsg) { updateAuthState({ latestError: new Error(errorMsg), + statusMessage: { + type: "error", + message: errorMsg, + }, }); return; } - if (restoredState && authorizationCode) { - let currentState: AuthDebuggerState = { - ...restoredState, - authorizationCode, - oauthStep: "token_request", - isInitiatingAuth: true, - statusMessage: null, - latestError: null, - }; - - try { - const stateMachine = new OAuthStateMachine(sseUrl, (updates) => { - currentState = { ...currentState, ...updates }; - }); - - while ( - currentState.oauthStep !== "complete" && - currentState.oauthStep !== "authorization_code" - ) { - await stateMachine.executeStep(currentState); - } - - if (currentState.oauthStep === "complete") { - updateAuthState({ - ...currentState, - statusMessage: { - type: "success", - message: "Authentication completed successfully", - }, - isInitiatingAuth: false, - }); - } - } catch (error) { - console.error("OAuth continuation error:", error); - updateAuthState({ - latestError: - error instanceof Error ? error : new Error(String(error)), - statusMessage: { - type: "error", - message: `Failed to complete OAuth flow: ${error instanceof Error ? error.message : String(error)}`, - }, - isInitiatingAuth: false, - }); - } - } else if (authorizationCode) { + if (authorizationCode) { + // Show info message - the user should use the debug flow to complete updateAuthState({ - authorizationCode, - oauthStep: "token_request", + statusMessage: { + type: "info", + message: + "Authorization code received. Use the debug flow to complete authentication.", + }, }); } }, - [sseUrl], + [], ); useEffect(() => { @@ -1177,15 +1140,21 @@ const App = () => { setLogLevel(level); }; - const AuthDebuggerWrapper = () => ( - - setIsAuthDebuggerVisible(false)} - authState={authState} - updateAuthState={updateAuthState} - /> - + // Memoize the AuthDebugger to prevent re-mounting when App re-renders + // This is important because AuthDebugger has local state (showDebugFlow) + // that would be lost if the component was re-mounted. + const authDebuggerElement = useMemo( + () => ( + + setIsAuthDebuggerVisible(false)} + authState={authState} + updateAuthState={updateAuthState} + /> + + ), + [sseUrl, authState, updateAuthState], ); if (window.location.pathname === "/oauth/callback") { @@ -1522,7 +1491,7 @@ const App = () => { setRoots={setRoots} onRootsChange={handleRootsChange} /> - + {authDebuggerElement} { className="w-full p-4" onValueChange={(value) => (window.location.hash = value)} > - + {authDebuggerElement} ) : (
diff --git a/client/src/__tests__/App.config.test.tsx b/client/src/__tests__/App.config.test.tsx index 7458c2055..faf0ac823 100644 --- a/client/src/__tests__/App.config.test.tsx +++ b/client/src/__tests__/App.config.test.tsx @@ -9,10 +9,6 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn(), })); -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - jest.mock("../lib/auth", () => ({ InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ tokens: jest.fn().mockResolvedValue(null), diff --git a/client/src/__tests__/App.routing.test.tsx b/client/src/__tests__/App.routing.test.tsx index 4713bef9a..cf3b38aac 100644 --- a/client/src/__tests__/App.routing.test.tsx +++ b/client/src/__tests__/App.routing.test.tsx @@ -8,10 +8,6 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn(), })); -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - jest.mock("../lib/auth", () => ({ InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ tokens: jest.fn().mockResolvedValue(null), diff --git a/client/src/__tests__/App.samplingNavigation.test.tsx b/client/src/__tests__/App.samplingNavigation.test.tsx index 70a42a92f..d10d1b7c3 100644 --- a/client/src/__tests__/App.samplingNavigation.test.tsx +++ b/client/src/__tests__/App.samplingNavigation.test.tsx @@ -32,10 +32,6 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn(), })); -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - jest.mock("../lib/auth", () => ({ InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ tokens: jest.fn().mockResolvedValue(null), diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 6252c1161..c6b8c9779 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -1,12 +1,10 @@ -import { useCallback, useMemo, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { DebugInspectorOAuthClientProvider } from "../lib/auth"; -import { AlertCircle } from "lucide-react"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types"; -import { OAuthFlowProgress } from "./OAuthFlowProgress"; -import { OAuthStateMachine } from "../lib/oauth-state-machine"; -import { SESSION_KEYS } from "../lib/constants"; -import { validateRedirectUrl } from "@/utils/urlValidation"; +import { AuthDebuggerFlow } from "./AuthDebuggerFlow"; +import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; export interface AuthDebuggerProps { serverUrl: string; @@ -23,6 +21,7 @@ const StatusMessage = ({ message }: StatusMessageProps) => { let bgColor: string; let textColor: string; let borderColor: string; + let Icon = AlertCircle; switch (message.type) { case "error": @@ -34,6 +33,7 @@ const StatusMessage = ({ message }: StatusMessageProps) => { bgColor = "bg-green-50"; textColor = "text-green-700"; borderColor = "border-green-200"; + Icon = CheckCircle2; break; case "info": default: @@ -48,7 +48,7 @@ const StatusMessage = ({ message }: StatusMessageProps) => { className={`p-3 rounded-md border ${bgColor} ${borderColor} ${textColor} mb-4`} >
- +

{message.message}

@@ -56,11 +56,14 @@ const StatusMessage = ({ message }: StatusMessageProps) => { }; const AuthDebugger = ({ - serverUrl: serverUrl, + serverUrl, onBack, authState, updateAuthState, }: AuthDebuggerProps) => { + const [flowMode, setFlowMode] = useState<"debug" | "quick" | null>(null); + const [flowComplete, setFlowComplete] = useState(false); + // Check for existing tokens on mount useEffect(() => { if (serverUrl && !authState.oauthTokens) { @@ -82,139 +85,69 @@ const AuthDebugger = ({ } }, [serverUrl, updateAuthState, authState.oauthTokens]); - const startOAuthFlow = useCallback(() => { - if (!serverUrl) { - updateAuthState({ - statusMessage: { - type: "error", - message: - "Please enter a server URL in the sidebar before authenticating", - }, - }); - return; - } - - updateAuthState({ - oauthStep: "metadata_discovery", - authorizationUrl: null, - statusMessage: null, - latestError: null, - }); - }, [serverUrl, updateAuthState]); - - const stateMachine = useMemo( - () => new OAuthStateMachine(serverUrl, updateAuthState), - [serverUrl, updateAuthState], - ); - - const proceedToNextStep = useCallback(async () => { - if (!serverUrl) return; + const startFlow = useCallback( + (mode: "debug" | "quick") => { + if (!serverUrl) { + updateAuthState({ + statusMessage: { + type: "error", + message: + "Please enter a server URL in the sidebar before authenticating", + }, + }); + return; + } - try { updateAuthState({ - isInitiatingAuth: true, statusMessage: null, latestError: null, }); + setFlowComplete(false); + setFlowMode(mode); + }, + [serverUrl, updateAuthState], + ); - await stateMachine.executeStep(authState); - } catch (error) { - console.error("OAuth flow error:", error); - updateAuthState({ - latestError: error instanceof Error ? error : new Error(String(error)), - }); - } finally { - updateAuthState({ isInitiatingAuth: false }); - } - }, [serverUrl, authState, updateAuthState, stateMachine]); + const startRunFlow = useCallback(() => startFlow("quick"), [startFlow]); + const startSlowFlow = useCallback(() => startFlow("debug"), [startFlow]); - const handleQuickOAuth = useCallback(async () => { - if (!serverUrl) { + const handleFlowComplete = useCallback( + (tokens: OAuthTokens) => { updateAuthState({ - statusMessage: { - type: "error", - message: - "Please enter a server URL in the sidebar before authenticating", - }, + oauthTokens: tokens, + oauthStep: "complete", }); - return; - } - - updateAuthState({ isInitiatingAuth: true, statusMessage: null }); - try { - // Step through the OAuth flow using the state machine instead of the auth() function - let currentState: AuthDebuggerState = { - ...authState, - oauthStep: "metadata_discovery", - authorizationUrl: null, - latestError: null, - }; - - const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => { - // Update our temporary state during the process - currentState = { ...currentState, ...updates }; - // But don't call updateAuthState yet - }); - - // Manually step through each stage of the OAuth flow - while (currentState.oauthStep !== "complete") { - await oauthMachine.executeStep(currentState); - // In quick mode, we'll just redirect to the authorization URL - if ( - currentState.oauthStep === "authorization_code" && - currentState.authorizationUrl - ) { - // Validate the URL before redirecting - try { - validateRedirectUrl(currentState.authorizationUrl); - } catch (error) { - updateAuthState({ - ...currentState, - isInitiatingAuth: false, - latestError: - error instanceof Error ? error : new Error(String(error)), - statusMessage: { - type: "error", - message: `Invalid authorization URL: ${error instanceof Error ? error.message : String(error)}`, - }, - }); - return; - } + // Keep the flow visible but mark as complete + setFlowComplete(true); + }, + [updateAuthState], + ); - // Store the current auth state before redirecting - sessionStorage.setItem( - SESSION_KEYS.AUTH_DEBUGGER_STATE, - JSON.stringify(currentState), - ); - // Open the authorization URL automatically - window.location.href = currentState.authorizationUrl.toString(); - break; - } - } + const handleFlowCancel = useCallback(() => { + setFlowMode(null); + setFlowComplete(false); + updateAuthState({ + statusMessage: { + type: "info", + message: "OAuth flow cancelled", + }, + }); + }, [updateAuthState]); - // After the flow completes or reaches a user-input step, update the app state - updateAuthState({ - ...currentState, - statusMessage: { - type: "info", - message: - currentState.oauthStep === "complete" - ? "Authentication completed successfully" - : "Please complete authentication in the opened window and enter the code", - }, - }); - } catch (error) { - console.error("OAuth initialization error:", error); + const handleFlowError = useCallback( + (error: Error) => { + setFlowMode(null); + setFlowComplete(false); updateAuthState({ + latestError: error, statusMessage: { type: "error", - message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`, + message: `OAuth flow failed: ${error.message}`, }, }); - } finally { - updateAuthState({ isInitiatingAuth: false }); - } - }, [serverUrl, updateAuthState, authState]); + }, + [updateAuthState], + ); const handleClearOAuth = useCallback(() => { if (serverUrl) { @@ -229,6 +162,8 @@ const AuthDebugger = ({ message: "OAuth tokens cleared successfully", }, }); + setFlowMode(null); + setFlowComplete(false); // Clear success message after 3 seconds setTimeout(() => { @@ -237,6 +172,11 @@ const AuthDebugger = ({ } }, [serverUrl, updateAuthState]); + const handleNewFlow = useCallback(() => { + setFlowMode(null); + setFlowComplete(false); + }, []); + return (
@@ -273,46 +213,50 @@ const AuthDebugger = ({
)} -
+
+ + {flowComplete && ( + + )}

- Choose "Guided" for step-by-step instructions or "Quick" for - the standard automatic flow. + Run Flow: Run the entire OAuth flow + automatically. Slow Mo: Step through each + request one at a time.

- + {flowMode && ( + + )} diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx new file mode 100644 index 000000000..f3562926b --- /dev/null +++ b/client/src/components/AuthDebuggerFlow.tsx @@ -0,0 +1,797 @@ +/** + * OAuth debug flow component that shows each HTTP request individually + * with pause/continue functionality. + */ + +import { useState, useCallback, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { + AlertTriangle, + CheckCircle2, + Circle, + ExternalLink, + Info, + Loader2, +} from "lucide-react"; +import { createDebugFetch, DebugRequestResponse } from "@/lib/debug-middleware"; +import { DebugOAuthProvider } from "@/lib/DebugOAuthProvider"; +import { + auth, + exchangeAuthorization, + extractWWWAuthenticateParams, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { + OAuthTokens, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { validateRedirectUrl } from "@/utils/urlValidation"; +import { useToast } from "@/lib/hooks/useToast"; + +interface AuthDebuggerFlowProps { + serverUrl: string; + quickMode?: boolean; + onComplete: (tokens: OAuthTokens) => void; + onCancel: () => void; + onError: (error: Error) => void; +} + +type FlowState = + | "running" + | "waiting_continue" + | "waiting_code" + | "complete" + | "error"; + +export function AuthDebuggerFlow({ + serverUrl, + quickMode = false, + onComplete, + onCancel, + onError, +}: AuthDebuggerFlowProps) { + const { toast } = useToast(); + const [completedSteps, setCompletedSteps] = useState( + [], + ); + const [currentStep, setCurrentStep] = useState( + null, + ); + const [flowState, setFlowState] = useState("running"); + const [authUrl, setAuthUrl] = useState(null); + const [authCode, setAuthCode] = useState(""); + + // Use refs to store resolvers so they persist across renders + const continueResolverRef = useRef<(() => void) | null>(null); + const authCodeResolverRef = useRef<((code: string) => void) | null>(null); + const flowStartedRef = useRef(false); + const popupRef = useRef(null); + + // Cache discovered metadata to avoid re-fetching during token exchange + // TODO: The SDK's auth() function should accept pre-fetched metadata to avoid + // redundant discovery requests. For now, we capture it from responses and use + // exchangeAuthorization() directly for the token exchange step. + const cachedMetadataRef = useRef<{ + authServerMetadata?: OAuthMetadata; + authServerUrl?: string; + resource?: URL; + }>({}); + + // Track discovery state to inject informational warnings + const discoveryStateRef = useRef<{ + prmFailed: boolean; + shownPrmWarning: boolean; + oauthMetadataSuccess: boolean; + shownNoMetadataWarning: boolean; + }>({ + prmFailed: false, + shownPrmWarning: false, + oauthMetadataSuccess: false, + shownNoMetadataWarning: false, + }); + + // Handler for middleware callback - pauses until Continue clicked (unless quickMode) + const handleRequestComplete = useCallback( + async (entry: DebugRequestResponse) => { + if (quickMode) { + // Quick mode: add directly to completed, don't pause + setCompletedSteps((prev) => [...prev, entry]); + return; + } + + // Debug mode: show as current step, wait for Continue, then add to completed + setCurrentStep(entry); + setFlowState("waiting_continue"); + await new Promise((resolve) => { + continueResolverRef.current = resolve; + }); + setCompletedSteps((prev) => [...prev, entry]); + setCurrentStep(null); + setFlowState("running"); + }, + [quickMode], + ); + + // Handler for auth URL - pauses until code entered + const handleAwaitAuthCode = useCallback(async (url: URL): Promise => { + setAuthUrl(url); + setFlowState("waiting_code"); + return new Promise((resolve) => { + authCodeResolverRef.current = resolve; + }); + }, []); + + const handleContinue = useCallback(() => { + if (continueResolverRef.current) { + continueResolverRef.current(); + continueResolverRef.current = null; + } + }, []); + + const handleSubmitCode = useCallback(() => { + if (authCodeResolverRef.current && authCode.trim()) { + authCodeResolverRef.current(authCode.trim()); + authCodeResolverRef.current = null; + setAuthUrl(null); + setAuthCode(""); + setFlowState("running"); + } + }, [authCode]); + + // Start the flow + useEffect(() => { + if (flowStartedRef.current) return; + flowStartedRef.current = true; + + // Helper to create error steps + function createErrorStep( + label: string, + message: React.ReactNode, + ): DebugRequestResponse { + return { + id: crypto.randomUUID(), + label, + request: { method: "ERROR", url: "", headers: {} }, + response: { + status: 0, + statusText: "Error", + headers: {}, + body: { message }, + }, + }; + } + + async function runDebugFlow() { + const baseDebugFetch = createDebugFetch(handleRequestComplete); + + // Wrap debugFetch to capture metadata and inject info/warning steps + const debugFetch: typeof fetch = async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + const method = init?.method || "GET"; + + // Before OAuth metadata request - inject PRM warning if needed + if ( + (url.includes(".well-known/oauth-authorization-server") || + url.includes(".well-known/openid-configuration")) && + discoveryStateRef.current.prmFailed && + !discoveryStateRef.current.shownPrmWarning + ) { + const errorEntry = createErrorStep( + "Error: No PRM Found", + <> + Server does not have Protected Resource Metadata. Falling back to{" "} + + 2025-03-26 spec + {" "} + authorization base URL discovery. This often is due to an + incorrect server URL preventing PRM discovery. Please double-check + the server URL is correct. + , + ); + await handleRequestComplete(errorEntry); + discoveryStateRef.current.shownPrmWarning = true; + } + + // Before POST /register - inject warning if no metadata found + if ( + method === "POST" && + url.includes("/register") && + !discoveryStateRef.current.oauthMetadataSuccess && + !discoveryStateRef.current.shownNoMetadataWarning + ) { + const errorEntry = createErrorStep( + "Error: No Metadata Found", + <> + Failed to discover OAuth authorization server metadata. Falling + back to{" "} + + 2025-03-26 spec + {" "} + server-without-metadata mode. This is unlikely to work, and is + often due to an incorrect server URL. Please check the MCP URL you + entered is correct. + , + ); + await handleRequestComplete(errorEntry); + discoveryStateRef.current.shownNoMetadataWarning = true; + } + + // Make the actual request + let response: Response; + try { + response = await baseDebugFetch(input, init); + } catch (error) { + // Track failures even when fetch throws (CORS, network errors) + if (url.includes(".well-known/oauth-protected-resource")) { + discoveryStateRef.current.prmFailed = true; + } + throw error; + } + + // Track PRM discovery + if (url.includes(".well-known/oauth-protected-resource")) { + if (!response.ok) { + discoveryStateRef.current.prmFailed = true; + } + } + + // Capture and track OAuth metadata + if ( + url.includes(".well-known/oauth-authorization-server") || + url.includes(".well-known/openid-configuration") + ) { + if (response.ok) { + discoveryStateRef.current.oauthMetadataSuccess = true; + try { + const cloned = response.clone(); + const metadata = await cloned.json(); + cachedMetadataRef.current.authServerMetadata = metadata; + const authServerUrl = new URL(url); + cachedMetadataRef.current.authServerUrl = authServerUrl.origin; + } catch { + // Ignore parse errors + } + } + } + + return response; + }; + + const provider = new DebugOAuthProvider(serverUrl); + provider.setAuthCodeHandler(handleAwaitAuthCode); + + try { + // Step 1: Try to initialize to get 401 with WWW-Authenticate header + let resourceMetadataUrl: URL | undefined; + let scope: string | undefined; + + try { + const initResponse = await debugFetch(serverUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "mcp-inspector", version: "1.0.0" }, + }, + id: 1, + }), + }); + + if (initResponse.status === 401) { + const params = extractWWWAuthenticateParams(initResponse); + resourceMetadataUrl = params.resourceMetadataUrl; + scope = params.scope; + } else if (initResponse.ok) { + onError( + new Error( + `Server returned ${initResponse.status} - authentication may not be required`, + ), + ); + return; + } + // For other non-401 errors, we'll continue without the metadata + } catch (fetchError) { + // Network errors (CORS preflight failure, connection refused, etc.) + // Record this as a failed step but continue with discovery + const errorMessage = + fetchError instanceof TypeError + ? "CORS preflight failed - server may not allow cross-origin requests" + : fetchError instanceof Error + ? fetchError.message + : String(fetchError); + + // Add a "failed" step to show what happened + const failedEntry: DebugRequestResponse = { + id: crypto.randomUUID(), + label: `POST ${new URL(serverUrl).pathname || "/"}`, + request: { + method: "POST", + url: serverUrl, + headers: { "Content-Type": "application/json" }, + body: { + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "mcp-inspector", version: "1.0.0" }, + }, + id: 1, + }, + }, + response: { + status: 0, + statusText: "Failed", + headers: {}, + body: { + error: errorMessage, + note: "Continuing with OAuth discovery without WWW-Authenticate metadata", + }, + }, + }; + + if (quickMode) { + // Quick mode: add directly to completed + setCompletedSteps((prev) => [...prev, failedEntry]); + } else { + // Debug mode: show as current step, wait for continue, then add to completed + setCurrentStep(failedEntry); + setFlowState("waiting_continue"); + await new Promise((resolve) => { + continueResolverRef.current = resolve; + }); + setCompletedSteps((prev) => [...prev, failedEntry]); + setCurrentStep(null); + setFlowState("running"); + } + } + + // Step 2: Run auth() for discovery, registration, and authorization start + // The debugFetch wrapper captures auth server metadata for later use + const result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope, + fetchFn: debugFetch, + }); + + // Step 3: If REDIRECT, use exchangeAuthorization directly with cached metadata + // This avoids the redundant metadata discovery that auth() would do + if (result === "REDIRECT") { + const authorizationCode = provider.getPendingAuthCode(); + if (!authorizationCode) { + onError(new Error("No authorization code received")); + return; + } + + provider.clearPendingAuthCode(); + + const clientInfo = await provider.clientInformation(); + if (!clientInfo) { + onError(new Error("No client information available")); + return; + } + + const codeVerifier = provider.codeVerifier(); + const { authServerMetadata, authServerUrl } = + cachedMetadataRef.current; + + if (!authServerUrl) { + onError(new Error("No auth server URL cached")); + return; + } + + // Use exchangeAuthorization directly instead of auth() + const tokens = await exchangeAuthorization(authServerUrl, { + metadata: authServerMetadata, + clientInformation: clientInfo, + authorizationCode, + codeVerifier, + redirectUri: provider.redirectUrl, + resource: cachedMetadataRef.current.resource, + fetchFn: debugFetch, + }); + + provider.saveTokens(tokens); + setFlowState("complete"); + onComplete(tokens); + return; + } + + if (result === "AUTHORIZED") { + const tokens = await provider.tokens(); + if (tokens) { + setFlowState("complete"); + onComplete(tokens); + } else { + onError(new Error("No tokens received after authorization")); + } + } + } catch (error) { + console.error("OAuth debug flow error:", error); + + // Show the final error as a step instead of dismissing the flow + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorEntry = createErrorStep( + "Flow Error", + <> + {errorMessage} +
+ + The OAuth flow could not complete. This may be due to CORS + restrictions or server configuration. + + , + ); + + if (quickMode) { + setCompletedSteps((prev) => [...prev, errorEntry]); + setFlowState("error"); + } else { + setCurrentStep(errorEntry); + setFlowState("waiting_continue"); + await new Promise((resolve) => { + continueResolverRef.current = resolve; + }); + setCompletedSteps((prev) => [...prev, errorEntry]); + setCurrentStep(null); + setFlowState("error"); + } + } + } + + runDebugFlow(); + }, [ + serverUrl, + handleRequestComplete, + handleAwaitAuthCode, + onComplete, + onError, + ]); + + // Listen for postMessage from popup (opener pattern) + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + // Only accept messages from same origin + if (event.origin !== window.location.origin) return; + if (event.data?.type === "oauth-callback" && event.data?.code) { + // Auto-fill the auth code from popup + if (authCodeResolverRef.current) { + authCodeResolverRef.current(event.data.code); + authCodeResolverRef.current = null; + setAuthUrl(null); + setAuthCode(""); + setFlowState("running"); + } + // Close popup if we opened it + popupRef.current?.close(); + popupRef.current = null; + } + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, []); + + const handleOpenAuthUrl = () => { + if (authUrl) { + try { + validateRedirectUrl(authUrl.href); + // Open as popup and keep reference for message receiving + popupRef.current = window.open( + authUrl.href, + "oauth-popup", + "width=600,height=700", + ); + } catch (error) { + toast({ + title: "Invalid URL", + description: + error instanceof Error + ? error.message + : "The authorization URL is not valid", + variant: "destructive", + }); + } + } + }; + + return ( +
+
+

OAuth Debug Flow

+ +
+ +

+ Step through the OAuth flow one request at a time. +

+ +
+ {/* Completed steps */} + {completedSteps.map((step, index) => ( + + ))} + + {/* Current step waiting for continue */} + {currentStep && flowState === "waiting_continue" && ( +
+ +
+ +
+
+ )} + + {/* Auth code entry */} + {flowState === "waiting_code" && authUrl && ( +
+
+

Authorization Required

+

+ Open this URL in a new tab to authorize: +

+
+ {authUrl.href} + +
+
+ +
+ +
+ setAuthCode(e.target.value)} + placeholder="Paste the authorization code here" + className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" + onKeyDown={(e) => { + if (e.key === "Enter" && authCode.trim()) { + handleSubmitCode(); + } + }} + /> + +
+

+ After authorizing in the opened window, paste the code here. +

+
+
+ )} + + {/* Running indicator - show when running and no current step to display */} + {flowState === "running" && !currentStep && ( +
+ + + {completedSteps.length === 0 + ? "Connecting to server..." + : "Processing..."} + +
+ )} + + {/* Complete indicator */} + {flowState === "complete" && ( +
+ + + Authentication completed successfully + +
+ )} + + {/* Error indicator */} + {flowState === "error" && ( +
+ + + Authentication flow failed + +
+ )} +
+
+ ); +} + +interface StepDisplayProps { + step: DebugRequestResponse; + stepNumber: number; + isComplete: boolean; + isCurrent: boolean; +} + +function StepDisplay({ + step, + stepNumber, + isComplete, + isCurrent, +}: StepDisplayProps) { + // Expand by default for current step, warnings, and errors + const [expanded, setExpanded] = useState( + isCurrent || + step.request.method === "WARNING" || + step.request.method === "ERROR", + ); + + return ( +
+
setExpanded(!expanded)} + > + {isComplete ? ( + step.response.status === 0 ? ( + // Status 0 = special step (info, warning, or error) + step.response.statusText === "Info" ? ( + + ) : step.response.statusText === "Warning" ? ( + + ) : ( + + ) + ) : step.response.status >= 400 ? ( + + ) : ( + + ) + ) : ( + + )} + + {stepNumber}. {step.label} + + {/* Hide status for info/warning/error steps, show for HTTP requests */} + {step.request.method !== "INFO" && + step.request.method !== "WARNING" && + step.request.method !== "ERROR" && ( + + {step.response.status} {step.response.statusText} + + )} +
+ + {expanded && ( +
+ {/* Info/Warning/Error steps: show message directly */} + {(step.request.method === "INFO" || + step.request.method === "WARNING" || + step.request.method === "ERROR") && ( +
+ {typeof step.response.body === "object" && + step.response.body !== null && + "message" in step.response.body + ? (step.response.body as { message: React.ReactNode }).message + : JSON.stringify(step.response.body)} +
+ )} + + {/* Regular HTTP requests: show Request/Response details */} + {step.request.method !== "INFO" && + step.request.method !== "WARNING" && + step.request.method !== "ERROR" && ( + <> + {/* Request details */} +
+ + Request + +
+
+ {step.request.method}{" "} + {step.request.url} +
+ {Object.keys(step.request.headers).length > 0 && ( +
+

Headers:

+
+                          {JSON.stringify(step.request.headers, null, 2)}
+                        
+
+ )} + {step.request.body !== undefined && ( +
+

Body:

+
+                          {typeof step.request.body === "string"
+                            ? step.request.body
+                            : JSON.stringify(step.request.body, null, 2)}
+                        
+
+ )} +
+
+ + {/* Response details */} +
+ + Response + +
+
+ = 400 ? "text-red-600" : "text-green-600"}`} + > + {step.response.status} + {" "} + {step.response.statusText} +
+ {Object.keys(step.response.headers).length > 0 && ( +
+

Headers:

+
+                          {JSON.stringify(step.response.headers, null, 2)}
+                        
+
+ )} + {step.response.body !== undefined && ( +
+

Body:

+
+                          {typeof step.response.body === "string"
+                            ? step.response.body
+                            : JSON.stringify(step.response.body, null, 2)}
+                        
+
+ )} +
+
+ + )} +
+ )} +
+ ); +} diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 95ccc0760..d05682aa5 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -30,6 +30,23 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { isProcessed = true; const params = parseOAuthCallbackParams(window.location.search); + + // Check if we're in a popup opened by the auth debugger + // If so, send the code via postMessage and close + if (window.opener && params.successful && "code" in params) { + try { + window.opener.postMessage( + { type: "oauth-callback", code: params.code }, + window.location.origin, + ); + // Close the popup after sending the message + window.close(); + return; + } catch { + // Fall through to normal handling if postMessage fails + } + } + if (!params.successful) { const errorMsg = generateOAuthErrorDescription(params); onConnect({ errorMsg }); diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx deleted file mode 100644 index 6e0fd6956..000000000 --- a/client/src/components/OAuthFlowProgress.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types"; -import { CheckCircle2, Circle, ExternalLink } from "lucide-react"; -import { Button } from "./ui/button"; -import { DebugInspectorOAuthClientProvider } from "@/lib/auth"; -import { useEffect, useMemo, useState } from "react"; -import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { validateRedirectUrl } from "@/utils/urlValidation"; -import { useToast } from "@/lib/hooks/useToast"; - -interface OAuthStepProps { - label: string; - isComplete: boolean; - isCurrent: boolean; - error?: Error | null; - children?: React.ReactNode; -} - -const OAuthStepDetails = ({ - label, - isComplete, - isCurrent, - error, - children, -}: OAuthStepProps) => { - return ( -
-
- {isComplete ? ( - - ) : ( - - )} - {label} -
- - {/* Show children if current step or complete and children exist */} - {(isCurrent || isComplete) && children && ( -
{children}
- )} - - {/* Display error if current step and an error exists */} - {isCurrent && error && ( -
-

Error:

-

{error.message}

-
- )} -
- ); -}; - -interface OAuthFlowProgressProps { - serverUrl: string; - authState: AuthDebuggerState; - updateAuthState: (updates: Partial) => void; - proceedToNextStep: () => Promise; -} - -const steps: Array = [ - "metadata_discovery", - "client_registration", - "authorization_redirect", - "authorization_code", - "token_request", - "complete", -]; - -export const OAuthFlowProgress = ({ - serverUrl, - authState, - updateAuthState, - proceedToNextStep, -}: OAuthFlowProgressProps) => { - const { toast } = useToast(); - const provider = useMemo( - () => new DebugInspectorOAuthClientProvider(serverUrl), - [serverUrl], - ); - const [clientInfo, setClientInfo] = useState( - null, - ); - - const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep); - - useEffect(() => { - const fetchClientInfo = async () => { - if (authState.oauthClientInfo) { - setClientInfo(authState.oauthClientInfo); - } else { - try { - const info = await provider.clientInformation(); - if (info) { - setClientInfo(info); - } - } catch (error) { - console.error("Failed to fetch client information:", error); - } - } - }; - - if (currentStepIdx > steps.indexOf("client_registration")) { - fetchClientInfo(); - } - }, [ - provider, - authState.oauthStep, - authState.oauthClientInfo, - currentStepIdx, - ]); - - // Helper to get step props - const getStepProps = (stepName: OAuthStep) => ({ - isComplete: - currentStepIdx > steps.indexOf(stepName) || - currentStepIdx === steps.length - 1, // last step is "complete" - isCurrent: authState.oauthStep === stepName, - error: authState.oauthStep === stepName ? authState.latestError : null, - }); - - return ( -
-

OAuth Flow Progress

-

- Follow these steps to complete OAuth authentication with the server. -

- -
- - {authState.oauthMetadata && ( -
- - OAuth Metadata Sources - {!authState.resourceMetadata && " ℹ️"} - - - {authState.resourceMetadata && ( -
-

Resource Metadata:

-

- From{" "} - { - new URL( - "/.well-known/oauth-protected-resource", - serverUrl, - ).href - } -

-
-                    {JSON.stringify(authState.resourceMetadata, null, 2)}
-                  
-
- )} - - {authState.resourceMetadataError && ( -
-

- ℹ️ Problem with resource metadata from{" "} - - { - new URL( - "/.well-known/oauth-protected-resource", - serverUrl, - ).href - } - -

-

- Resource metadata was added in the{" "} - - 2025-06-18 specification update - -
- {authState.resourceMetadataError.message} - {authState.resourceMetadataError instanceof TypeError && - " (This could indicate the endpoint doesn't exist or does not have CORS configured)"} -

-
- )} - - {authState.oauthMetadata && ( -
-

Authorization Server Metadata:

- {authState.authServerUrl && ( -

- From{" "} - { - new URL( - "/.well-known/oauth-authorization-server", - authState.authServerUrl, - ).href - } -

- )} -
-                    {JSON.stringify(authState.oauthMetadata, null, 2)}
-                  
-
- )} -
- )} -
- - - {clientInfo && ( -
- - Registered Client Information - -
-                {JSON.stringify(clientInfo, null, 2)}
-              
-
- )} -
- - - {authState.authorizationUrl && ( -
-

Authorization URL:

-
-

- {String(authState.authorizationUrl)} -

- -
-

- Click the link to authorize in your browser. After - authorization, you'll be redirected back to continue the flow. -

-
- )} -
- - -
- -
- { - updateAuthState({ - authorizationCode: e.target.value, - validationError: null, - }); - }} - placeholder="Enter the code from the authorization server" - className={`flex h-9 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${ - authState.validationError ? "border-red-500" : "border-input" - }`} - /> -
- {authState.validationError && ( -

- {authState.validationError} -

- )} -

- Once you've completed authorization in the link, paste the code - here. -

-
-
- - - {authState.oauthMetadata && ( -
- - Token Request Details - -
-

Token Endpoint:

- - {authState.oauthMetadata.token_endpoint} - -
-
- )} -
- - - {authState.oauthTokens && ( -
- - Access Tokens - -

- Authentication successful! You can now use the authenticated - connection. These tokens will be used automatically for server - requests. -

-
-                {JSON.stringify(authState.oauthTokens, null, 2)}
-              
-
- )} -
-
- -
- {authState.oauthStep !== "complete" && ( - <> - - - )} - - {authState.oauthStep === "authorization_redirect" && - authState.authorizationUrl && ( - - )} -
-
- ); -}; diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 5d5042ea5..ce765da89 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -9,7 +9,7 @@ import "@testing-library/jest-dom"; import { describe, it, beforeEach, jest } from "@jest/globals"; import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { SESSION_KEYS } from "@/lib/constants"; +import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types"; const mockOAuthTokens = { access_token: "test_access_token", @@ -19,117 +19,35 @@ const mockOAuthTokens = { scope: "test_scope", }; -const mockOAuthMetadata = { - issuer: "https://oauth.example.com", - authorization_endpoint: "https://oauth.example.com/authorize", - token_endpoint: "https://oauth.example.com/token", - response_types_supported: ["code"], - grant_types_supported: ["authorization_code"], - scopes_supported: ["read", "write"], -}; - -const mockOAuthClientInfo = { - client_id: "test_client_id", - client_secret: "test_client_secret", - redirect_uris: ["http://localhost:3000/oauth/callback/debug"], -}; - -// Mock MCP SDK functions - must be before imports +// Mock MCP SDK functions jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn(), - discoverAuthorizationServerMetadata: jest.fn(), - registerClient: jest.fn(), - startAuthorization: jest.fn(), - exchangeAuthorization: jest.fn(), - discoverOAuthProtectedResourceMetadata: jest.fn(), - selectResourceURL: jest.fn(), + extractWWWAuthenticateParams: jest.fn().mockReturnValue({}), })); -// Import the functions to get their types -import { - discoverAuthorizationServerMetadata, - registerClient, - startAuthorization, - exchangeAuthorization, - auth, - discoverOAuthProtectedResourceMetadata, -} from "@modelcontextprotocol/sdk/client/auth.js"; -import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types"; - // Mock local auth module jest.mock("@/lib/auth", () => ({ DebugInspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ tokens: jest.fn().mockImplementation(() => Promise.resolve(undefined)), clear: jest.fn().mockImplementation(() => { - // Mock the real clear() behavior which removes items from sessionStorage + // Mock the real clear() behavior sessionStorage.removeItem("[https://example.com/mcp] mcp_tokens"); - sessionStorage.removeItem("[https://example.com/mcp] mcp_client_info"); - sessionStorage.removeItem( - "[https://example.com/mcp] mcp_server_metadata", - ); - }), - redirectUrl: "http://localhost:3000/oauth/callback/debug", - clientMetadata: { - redirect_uris: ["http://localhost:3000/oauth/callback/debug"], - token_endpoint_auth_method: "none", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - client_name: "MCP Inspector", - }, - clientInformation: jest.fn().mockImplementation(async () => { - const serverUrl = "https://example.com/mcp"; - const preregisteredKey = `[${serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}`; - const preregisteredData = sessionStorage.getItem(preregisteredKey); - if (preregisteredData) { - return JSON.parse(preregisteredData); - } - const dynamicKey = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`; - const dynamicData = sessionStorage.getItem(dynamicKey); - if (dynamicData) { - return JSON.parse(dynamicData); - } - return undefined; }), - saveClientInformation: jest.fn().mockImplementation((clientInfo) => { - const serverUrl = "https://example.com/mcp"; - const key = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`; - sessionStorage.setItem(key, JSON.stringify(clientInfo)); - }), - saveTokens: jest.fn(), - redirectToAuthorization: jest.fn(), - saveCodeVerifier: jest.fn(), - codeVerifier: jest.fn(), - saveServerMetadata: jest.fn(), - getServerMetadata: jest.fn(), })), - discoverScopes: jest.fn().mockResolvedValue("read write" as never), })); -import { discoverScopes } from "@/lib/auth"; - -// Type the mocked functions properly -const mockDiscoverAuthorizationServerMetadata = - discoverAuthorizationServerMetadata as jest.MockedFunction< - typeof discoverAuthorizationServerMetadata - >; -const mockRegisterClient = registerClient as jest.MockedFunction< - typeof registerClient ->; -const mockStartAuthorization = startAuthorization as jest.MockedFunction< - typeof startAuthorization ->; -const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction< - typeof exchangeAuthorization ->; -const mockAuth = auth as jest.MockedFunction; -const mockDiscoverOAuthProtectedResourceMetadata = - discoverOAuthProtectedResourceMetadata as jest.MockedFunction< - typeof discoverOAuthProtectedResourceMetadata - >; -const mockDiscoverScopes = discoverScopes as jest.MockedFunction< - typeof discoverScopes ->; +// Mock the AuthDebuggerFlow component since it has complex async behavior +jest.mock("../AuthDebuggerFlow", () => ({ + AuthDebuggerFlow: jest.fn(({ onComplete, onCancel, onError }) => ( +
+ + + +
+ )), +})); const sessionStorageMock = { getItem: jest.fn(), @@ -155,35 +73,8 @@ describe("AuthDebugger", () => { jest.clearAllMocks(); sessionStorageMock.getItem.mockReturnValue(null); - // Suppress console errors in tests to avoid JSDOM navigation noise + // Suppress console errors in tests jest.spyOn(console, "error").mockImplementation(() => {}); - - // Set default mock behaviors with complete OAuth metadata - mockDiscoverAuthorizationServerMetadata.mockResolvedValue({ - issuer: "https://oauth.example.com", - authorization_endpoint: "https://oauth.example.com/authorize", - token_endpoint: "https://oauth.example.com/token", - response_types_supported: ["code"], - grant_types_supported: ["authorization_code"], - scopes_supported: ["read", "write"], - }); - mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); - mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( - new Error("No protected resource metadata found"), - ); - mockStartAuthorization.mockImplementation(async (_sseUrl, options) => { - const authUrl = new URL("https://oauth.example.com/authorize"); - - if (options.scope) { - authUrl.searchParams.set("scope", options.scope); - } - - return { - authorizationUrl: authUrl, - codeVerifier: "test_verifier", - }; - }); - mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens); }); afterEach(() => { @@ -219,29 +110,46 @@ describe("AuthDebugger", () => { fireEvent.click(screen.getByText("Back to Connect")); expect(onBack).toHaveBeenCalled(); }); - }); - describe("OAuth Flow", () => { - it("should start OAuth flow when 'Guided OAuth Flow' is clicked", async () => { + it("should show Run Flow and Slow Mo buttons when no tokens exist", async () => { await act(async () => { renderAuthDebugger(); }); + expect( + screen.getByRole("button", { name: "Run Flow" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Slow Mo" }), + ).toBeInTheDocument(); + }); + it("should show Run Flow and Slow Mo buttons when tokens exist", async () => { await act(async () => { - fireEvent.click(screen.getByText("Guided OAuth Flow")); + renderAuthDebugger({ + authState: { + ...defaultAuthState, + oauthTokens: mockOAuthTokens, + }, + }); }); - - expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Run Flow" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Slow Mo" }), + ).toBeInTheDocument(); }); + }); - it("should show error when OAuth flow is started without sseUrl", async () => { + describe("Run Flow", () => { + it("should show error when debug flow is started without serverUrl", async () => { const updateAuthState = jest.fn(); await act(async () => { renderAuthDebugger({ serverUrl: "", updateAuthState }); }); await act(async () => { - fireEvent.click(screen.getByText("Guided OAuth Flow")); + fireEvent.click(screen.getByRole("button", { name: "Run Flow" })); }); expect(updateAuthState).toHaveBeenCalledWith({ @@ -253,52 +161,80 @@ describe("AuthDebugger", () => { }); }); - it("should start quick OAuth flow and properly fetch and save metadata", async () => { - // Setup the auth mock - mockAuth.mockResolvedValue("AUTHORIZED"); + it("should show AuthDebuggerFlow when debug flow is started", async () => { + await act(async () => { + renderAuthDebugger(); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Run Flow" })); + }); + + expect(screen.getByTestId("mock-auth-debugger-flow")).toBeInTheDocument(); + }); + it("should handle flow completion", async () => { const updateAuthState = jest.fn(); await act(async () => { renderAuthDebugger({ updateAuthState }); }); await act(async () => { - fireEvent.click(screen.getByText("Quick OAuth Flow")); + fireEvent.click(screen.getByRole("button", { name: "Run Flow" })); }); - // Should first discover and save OAuth metadata - expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( - new URL("https://example.com/"), - ); + await act(async () => { + fireEvent.click(screen.getByText("Mock Complete")); + }); - // Check that updateAuthState was called with the right info message - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - oauthStep: "authorization_code", - }), - ); + expect(updateAuthState).toHaveBeenCalledWith({ + oauthTokens: mockOAuthTokens, + oauthStep: "complete", + }); }); - it("should show error when quick OAuth flow fails to discover metadata", async () => { - mockDiscoverAuthorizationServerMetadata.mockRejectedValue( - new Error("Metadata discovery failed"), - ); + it("should handle flow cancellation", async () => { + const updateAuthState = jest.fn(); + await act(async () => { + renderAuthDebugger({ updateAuthState }); + }); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Run Flow" })); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Mock Cancel")); + }); + + expect(updateAuthState).toHaveBeenCalledWith({ + statusMessage: { + type: "info", + message: "OAuth flow cancelled", + }, + }); + }); + it("should handle flow error", async () => { const updateAuthState = jest.fn(); await act(async () => { renderAuthDebugger({ updateAuthState }); }); await act(async () => { - fireEvent.click(screen.getByText("Quick OAuth Flow")); + fireEvent.click(screen.getByRole("button", { name: "Run Flow" })); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Mock Error")); }); - // Check that updateAuthState was called with an error message expect(updateAuthState).toHaveBeenCalledWith( expect.objectContaining({ + latestError: expect.any(Error), statusMessage: { type: "error", - message: expect.stringContaining("Failed to start OAuth flow"), + message: "OAuth flow failed: Test error", }, }), ); @@ -306,15 +242,7 @@ describe("AuthDebugger", () => { }); describe("Session Storage Integration", () => { - it("should load OAuth tokens from session storage", async () => { - // Mock the specific key for tokens with server URL - sessionStorageMock.getItem.mockImplementation((key) => { - if (key === "[https://example.com] mcp_tokens") { - return JSON.stringify(mockOAuthTokens); - } - return null; - }); - + it("should display OAuth tokens when they exist", async () => { await act(async () => { renderAuthDebugger({ authState: { @@ -357,13 +285,6 @@ describe("AuthDebugger", () => { describe("OAuth State Management", () => { it("should clear OAuth state when Clear button is clicked", async () => { const updateAuthState = jest.fn(); - // Mock the session storage to return tokens for the specific key - sessionStorageMock.getItem.mockImplementation((key) => { - if (key === "[https://example.com] mcp_tokens") { - return JSON.stringify(mockOAuthTokens); - } - return null; - }); await act(async () => { renderAuthDebugger({ @@ -404,394 +325,53 @@ describe("AuthDebugger", () => { }); }); - describe("OAuth Flow Steps", () => { - it("should handle OAuth flow step progression", async () => { - const updateAuthState = jest.fn(); - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { - ...defaultAuthState, - isInitiatingAuth: false, // Changed to false so button is enabled - oauthStep: "metadata_discovery", - }, - }); - }); - - // Verify metadata discovery step - expect(screen.getByText("Metadata Discovery")).toBeInTheDocument(); - - // Click Continue - this should trigger metadata discovery - await act(async () => { - fireEvent.click(screen.getByText("Continue")); - }); - - expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( - new URL("https://example.com/"), - ); - }); - - // Setup helper for OAuth authorization tests - const setupAuthorizationUrlTest = async (metadata: OAuthMetadata) => { - const updateAuthState = jest.fn(); - - // Mock the session storage to return metadata - sessionStorageMock.getItem.mockImplementation((key) => { - if (key === `[https://example.com] ${SESSION_KEYS.SERVER_METADATA}`) { - return JSON.stringify(metadata); - } - if ( - key === `[https://example.com] ${SESSION_KEYS.CLIENT_INFORMATION}` - ) { - return JSON.stringify(mockOAuthClientInfo); - } - return null; - }); - + describe("Status Messages", () => { + it("should display success messages", async () => { await act(async () => { renderAuthDebugger({ - updateAuthState, authState: { ...defaultAuthState, - isInitiatingAuth: false, - oauthStep: "authorization_redirect", - oauthMetadata: metadata, - oauthClientInfo: mockOAuthClientInfo, + statusMessage: { + type: "success", + message: "Test success message", + }, }, }); }); - // Click Continue to trigger authorization - await act(async () => { - fireEvent.click(screen.getByText("Continue")); - }); - - return updateAuthState; - }; - - it("should include scope in authorization URL when scopes_supported is present", async () => { - const metadataWithScopes = { - ...mockOAuthMetadata, - scopes_supported: ["read", "write", "admin"], - }; - - const updateAuthState = - await setupAuthorizationUrlTest(metadataWithScopes); - - // Wait for the updateAuthState to be called - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationUrl: expect.objectContaining({ - href: "https://oauth.example.com/authorize?scope=read+write", - }), - }), - ); - }); - }); - - it("should include scope in authorization URL when scopes_supported is not present", async () => { - const updateAuthState = - await setupAuthorizationUrlTest(mockOAuthMetadata); - - // Wait for the updateAuthState to be called - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationUrl: expect.objectContaining({ - href: "https://oauth.example.com/authorize?scope=read+write", - }), - }), - ); - }); - }); - - it("should omit scope from authorization URL when discoverScopes returns undefined", async () => { - // Mock discoverScopes to return undefined (no scopes available) - mockDiscoverScopes.mockResolvedValueOnce(undefined); - - const updateAuthState = - await setupAuthorizationUrlTest(mockOAuthMetadata); - - // Wait for the updateAuthState to be called - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationUrl: expect.not.stringContaining("scope="), - }), - ); - }); + expect(screen.getByText("Test success message")).toBeInTheDocument(); }); - }); - - describe("Client Registration behavior", () => { - it("uses preregistered (static) client information without calling DCR", async () => { - const preregClientInfo = { - client_id: "static_client_id", - client_secret: "static_client_secret", - redirect_uris: ["http://localhost:3000/oauth/callback/debug"], - }; - - // Return preregistered client info for the server-specific key - sessionStorageMock.getItem.mockImplementation((key) => { - if ( - key === - `[${defaultProps.serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}` - ) { - return JSON.stringify(preregClientInfo); - } - return null; - }); - - const updateAuthState = jest.fn(); + it("should display error messages", async () => { await act(async () => { renderAuthDebugger({ - updateAuthState, authState: { ...defaultAuthState, - isInitiatingAuth: false, - oauthStep: "client_registration", - oauthMetadata: mockOAuthMetadata as unknown as OAuthMetadata, + statusMessage: { + type: "error", + message: "Test error message", + }, }, }); }); - // Proceed from client_registration → authorization_redirect - await act(async () => { - fireEvent.click(screen.getByText("Continue")); - }); - - // Should NOT attempt dynamic client registration - expect(mockRegisterClient).not.toHaveBeenCalled(); - - // Should advance with the preregistered client info - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - oauthClientInfo: expect.objectContaining({ - client_id: "static_client_id", - }), - oauthStep: "authorization_redirect", - }), - ); + expect(screen.getByText("Test error message")).toBeInTheDocument(); }); - it("falls back to DCR when no static client information is available", async () => { - // No preregistered or dynamic client info present in session storage - sessionStorageMock.getItem.mockImplementation(() => null); - - // DCR returns a new client - mockRegisterClient.mockResolvedValueOnce(mockOAuthClientInfo); - - const updateAuthState = jest.fn(); - + it("should display info messages", async () => { await act(async () => { renderAuthDebugger({ - updateAuthState, authState: { ...defaultAuthState, - isInitiatingAuth: false, - oauthStep: "client_registration", - oauthMetadata: mockOAuthMetadata as unknown as OAuthMetadata, + statusMessage: { + type: "info", + message: "Test info message", + }, }, }); }); - await act(async () => { - fireEvent.click(screen.getByText("Continue")); - }); - - expect(mockRegisterClient).toHaveBeenCalledTimes(1); - - // Should save and advance with the DCR client info - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - oauthClientInfo: expect.objectContaining({ - client_id: "test_client_id", - }), - oauthStep: "authorization_redirect", - }), - ); - - // Verify the dynamically registered client info was persisted - expect(sessionStorage.setItem).toHaveBeenCalledWith( - `[${defaultProps.serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`, - expect.any(String), - ); - }); - }); - - describe("OAuth State Persistence", () => { - it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => { - const updateAuthState = jest.fn(); - - // Setup mocks for OAuth flow - mockStartAuthorization.mockResolvedValue({ - authorizationUrl: new URL( - "https://oauth.example.com/authorize?client_id=test_client_id&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Foauth%2Fcallback%2Fdebug", - ), - codeVerifier: "test_verifier", - }); - - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { ...defaultAuthState }, - }); - }); - - // Click Quick OAuth Flow - await act(async () => { - fireEvent.click(screen.getByText("Quick OAuth Flow")); - }); - - // Wait for the flow to reach the authorization step - await waitFor(() => { - expect(sessionStorage.setItem).toHaveBeenCalledWith( - SESSION_KEYS.AUTH_DEBUGGER_STATE, - expect.stringContaining('"oauthStep":"authorization_code"'), - ); - }); - - // Verify the stored state includes all the accumulated data - const storedStateCall = ( - sessionStorage.setItem as jest.Mock - ).mock.calls.find((call) => call[0] === SESSION_KEYS.AUTH_DEBUGGER_STATE); - - expect(storedStateCall).toBeDefined(); - const storedState = JSON.parse(storedStateCall![1] as string); - - expect(storedState).toMatchObject({ - oauthStep: "authorization_code", - authorizationUrl: expect.stringMatching( - /^https:\/\/oauth\.example\.com\/authorize/, - ), - oauthMetadata: expect.objectContaining({ - token_endpoint: "https://oauth.example.com/token", - }), - oauthClientInfo: expect.objectContaining({ - client_id: "test_client_id", - }), - }); - }); - }); - - describe("OAuth Protected Resource Metadata", () => { - it("should successfully fetch and display protected resource metadata", async () => { - const updateAuthState = jest.fn(); - const mockResourceMetadata = { - resource: "https://example.com/mcp", - authorization_servers: ["https://custom-auth.example.com"], - bearer_methods_supported: ["header", "body"], - resource_documentation: "https://example.com/mcp/docs", - resource_policy_uri: "https://example.com/mcp/policy", - }; - - // Mock successful metadata discovery - mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue( - mockResourceMetadata, - ); - mockDiscoverAuthorizationServerMetadata.mockResolvedValue( - mockOAuthMetadata, - ); - - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { ...defaultAuthState }, - }); - }); - - // Click Guided OAuth Flow to start the process - await act(async () => { - fireEvent.click(screen.getByText("Guided OAuth Flow")); - }); - - // Verify that the flow started with metadata discovery - expect(updateAuthState).toHaveBeenCalledWith({ - oauthStep: "metadata_discovery", - authorizationUrl: null, - statusMessage: null, - latestError: null, - }); - - // Click Continue to trigger metadata discovery - const continueButton = await screen.findByText("Continue"); - await act(async () => { - fireEvent.click(continueButton); - }); - - // Wait for the metadata to be fetched - await waitFor(() => { - expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( - "https://example.com/mcp", - ); - }); - - // Verify the state was updated with the resource metadata - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - resourceMetadata: mockResourceMetadata, - authServerUrl: new URL("https://custom-auth.example.com"), - oauthStep: "client_registration", - }), - ); - }); - }); - - it("should handle protected resource metadata fetch failure gracefully", async () => { - const updateAuthState = jest.fn(); - const mockError = new Error("Failed to fetch resource metadata"); - - // Mock failed metadata discovery - mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(mockError); - // But OAuth metadata should still work with the original URL - mockDiscoverAuthorizationServerMetadata.mockResolvedValue( - mockOAuthMetadata, - ); - - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { ...defaultAuthState }, - }); - }); - - // Click Guided OAuth Flow - await act(async () => { - fireEvent.click(screen.getByText("Guided OAuth Flow")); - }); - - // Click Continue to trigger metadata discovery - const continueButton = await screen.findByText("Continue"); - await act(async () => { - fireEvent.click(continueButton); - }); - - // Wait for the metadata fetch to fail - await waitFor(() => { - expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( - "https://example.com/mcp", - ); - }); - - // Verify the flow continues despite the error - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - resourceMetadataError: mockError, - // Should use the original server URL as fallback - authServerUrl: new URL("https://example.com/"), - oauthStep: "client_registration", - }), - ); - }); - - // Verify that regular OAuth metadata discovery was still called - expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( - new URL("https://example.com/"), - ); + expect(screen.getByText("Test info message")).toBeInTheDocument(); }); }); }); diff --git a/client/src/lib/DebugOAuthProvider.ts b/client/src/lib/DebugOAuthProvider.ts new file mode 100644 index 000000000..33ad1c819 --- /dev/null +++ b/client/src/lib/DebugOAuthProvider.ts @@ -0,0 +1,185 @@ +/** + * OAuth provider for the debug flow that intercepts the authorization redirect + * and allows manual entry of the authorization code. + */ + +import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import { + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens, + OAuthTokensSchema, + OAuthClientInformationSchema, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { SESSION_KEYS, getServerSpecificKey } from "./constants"; +import { generateOAuthState } from "@/utils/oauthUtils"; + +/** + * Handler type for when the SDK wants to redirect to authorization. + * The handler receives the authorization URL and should return the + * authorization code once the user completes the flow. + */ +export type AuthCodeHandler = (url: URL) => Promise; + +/** + * Debug OAuth provider that extends the base provider to intercept + * authorization redirects and allow manual code entry. + */ +export class DebugOAuthProvider implements OAuthClientProvider { + private _authCodeHandler: AuthCodeHandler | null = null; + private _pendingAuthCode: string | null = null; + + constructor(private serverUrl: string) { + // Save the server URL to session storage + sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); + } + + /** + * Sets the handler that will be called when authorization is needed. + * The handler should display the URL to the user and wait for them + * to enter the authorization code. + */ + setAuthCodeHandler(handler: AuthCodeHandler) { + this._authCodeHandler = handler; + } + + /** + * Returns any pending authorization code that was received. + */ + getPendingAuthCode(): string | null { + return this._pendingAuthCode; + } + + /** + * Clears the pending authorization code. + */ + clearPendingAuthCode() { + this._pendingAuthCode = null; + } + + get redirectUrl(): string { + return window.location.origin + "/oauth/callback/debug"; + } + + get clientMetadata(): OAuthClientMetadata { + const metadata: OAuthClientMetadata = { + redirect_uris: [this.redirectUrl], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + client_name: "MCP Inspector", + client_uri: "https://github.com/modelcontextprotocol/inspector", + }; + return metadata; + } + + state(): string | Promise { + return generateOAuthState(); + } + + async clientInformation(): Promise { + const key = getServerSpecificKey( + SESSION_KEYS.CLIENT_INFORMATION, + this.serverUrl, + ); + const value = sessionStorage.getItem(key); + if (!value) { + return undefined; + } + return await OAuthClientInformationSchema.parseAsync(JSON.parse(value)); + } + + saveClientInformation(clientInformation: OAuthClientInformation) { + const key = getServerSpecificKey( + SESSION_KEYS.CLIENT_INFORMATION, + this.serverUrl, + ); + sessionStorage.setItem(key, JSON.stringify(clientInformation)); + } + + async tokens(): Promise { + const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl); + const tokens = sessionStorage.getItem(key); + if (!tokens) { + return undefined; + } + return await OAuthTokensSchema.parseAsync(JSON.parse(tokens)); + } + + saveTokens(tokens: OAuthTokens) { + const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl); + sessionStorage.setItem(key, JSON.stringify(tokens)); + } + + /** + * Instead of redirecting, this calls the auth code handler and waits + * for the user to manually enter the code. + */ + async redirectToAuthorization(authorizationUrl: URL): Promise { + if (!this._authCodeHandler) { + throw new Error( + "Auth code handler not set - call setAuthCodeHandler before starting auth flow", + ); + } + // This blocks until user enters the code in the UI + const code = await this._authCodeHandler(authorizationUrl); + // Store code for retrieval by the caller + this._pendingAuthCode = code; + } + + saveCodeVerifier(codeVerifier: string) { + const key = getServerSpecificKey( + SESSION_KEYS.CODE_VERIFIER, + this.serverUrl, + ); + sessionStorage.setItem(key, codeVerifier); + } + + codeVerifier(): string { + const key = getServerSpecificKey( + SESSION_KEYS.CODE_VERIFIER, + this.serverUrl, + ); + const verifier = sessionStorage.getItem(key); + if (!verifier) { + throw new Error("No code verifier saved for session"); + } + return verifier; + } + + // Additional methods for saving/retrieving server metadata for display + + saveServerMetadata(metadata: OAuthMetadata) { + const key = getServerSpecificKey( + SESSION_KEYS.SERVER_METADATA, + this.serverUrl, + ); + sessionStorage.setItem(key, JSON.stringify(metadata)); + } + + getServerMetadata(): OAuthMetadata | null { + const key = getServerSpecificKey( + SESSION_KEYS.SERVER_METADATA, + this.serverUrl, + ); + const metadata = sessionStorage.getItem(key); + if (!metadata) { + return null; + } + return JSON.parse(metadata); + } + + clear() { + const keys = [ + SESSION_KEYS.CLIENT_INFORMATION, + SESSION_KEYS.TOKENS, + SESSION_KEYS.CODE_VERIFIER, + SESSION_KEYS.SERVER_METADATA, + ]; + for (const baseKey of keys) { + const key = getServerSpecificKey(baseKey, this.serverUrl); + sessionStorage.removeItem(key); + } + } +} diff --git a/client/src/lib/debug-middleware.ts b/client/src/lib/debug-middleware.ts new file mode 100644 index 000000000..e9a396c3f --- /dev/null +++ b/client/src/lib/debug-middleware.ts @@ -0,0 +1,187 @@ +/** + * Debug middleware for capturing HTTP request/response pairs with pause functionality. + * Used by the auth debugger to show each OAuth request individually. + */ + +export interface DebugRequestResponse { + id: string; + label: string; + request: { + method: string; + url: string; + headers: Record; + body?: unknown; + }; + response: { + status: number; + statusText: string; + headers: Record; + body?: unknown; + }; +} + +export type DebugMiddlewareCallback = ( + entry: DebugRequestResponse, +) => Promise; + +/** + * Creates a fetch function that captures request/response pairs and pauses + * after each request completes, waiting for the callback to resolve. + * + * @param onComplete - Callback invoked after each request/response. The flow + * pauses until this promise resolves. + * @returns A fetch-like function that can be passed to SDK auth functions. + */ +export function createDebugFetch( + onComplete: DebugMiddlewareCallback, +): typeof fetch { + return async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + const method = init?.method || "GET"; + + // Parse request body if present (do this first so we can show it even on failure) + let requestBody: unknown = undefined; + if (init?.body) { + requestBody = parseBody(init.body); + } + + let response: Response; + try { + // Make the actual request + response = await fetch(input, init); + } catch (fetchError) { + // Request failed (CORS, network error, etc.) + // Show this as a failed step before re-throwing + const errorMessage = + fetchError instanceof TypeError + ? "Network error (CORS or connection failed)" + : fetchError instanceof Error + ? fetchError.message + : String(fetchError); + + const failedEntry: DebugRequestResponse = { + id: crypto.randomUUID(), + label: inferLabel(url, method), + request: { + method, + url, + headers: headersToObject(init?.headers), + body: requestBody, + }, + response: { + status: 0, + statusText: "Failed", + headers: {}, + body: { error: errorMessage }, + }, + }; + + await onComplete(failedEntry); + throw fetchError; // Re-throw so SDK can handle it + } + + // Clone to read body without consuming + const clonedResponse = response.clone(); + let responseBody: unknown; + try { + responseBody = await clonedResponse.json(); + } catch { + try { + responseBody = await clonedResponse.text(); + } catch { + responseBody = null; + } + } + + // Build entry and wait for user to continue + const entry: DebugRequestResponse = { + id: crypto.randomUUID(), + label: inferLabel(url, method), + request: { + method, + url, + headers: headersToObject(init?.headers), + body: requestBody, + }, + response: { + status: response.status, + statusText: response.statusText, + headers: headersToObject(response.headers), + body: responseBody, + }, + }; + + await onComplete(entry); // Blocks until user clicks Continue + + return response; + }; +} + +/** + * Creates a raw label showing the HTTP method and path. + */ +function inferLabel(url: string, method: string): string { + try { + const parsed = new URL(url); + return `${method} ${parsed.pathname}`; + } catch { + return `${method} ${url}`; + } +} + +/** + * Converts various header formats to a plain object. + */ +function headersToObject( + headers?: HeadersInit | Headers, +): Record { + if (!headers) return {}; + + if (headers instanceof Headers) { + const obj: Record = {}; + headers.forEach((value, key) => { + obj[key] = value; + }); + return obj; + } + + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + + return headers as Record; +} + +/** + * Parses a request body into a displayable format. + */ +function parseBody(body: BodyInit | null | undefined): unknown { + if (!body) return undefined; + + if (typeof body === "string") { + try { + return JSON.parse(body); + } catch { + // Check if it's URL-encoded form data + if (body.includes("=") && body.includes("&")) { + const params = new URLSearchParams(body); + const obj: Record = {}; + params.forEach((value, key) => { + obj[key] = value; + }); + return obj; + } + return body; + } + } + + if (body instanceof URLSearchParams) { + const obj: Record = {}; + body.forEach((value, key) => { + obj[key] = value; + }); + return obj; + } + + return "[Binary or unsupported body type]"; +} diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts deleted file mode 100644 index 8dc9da8f9..000000000 --- a/client/src/lib/oauth-state-machine.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { OAuthStep, AuthDebuggerState } from "./auth-types"; -import { DebugInspectorOAuthClientProvider, discoverScopes } from "./auth"; -import { - discoverAuthorizationServerMetadata, - registerClient, - startAuthorization, - exchangeAuthorization, - discoverOAuthProtectedResourceMetadata, - selectResourceURL, -} from "@modelcontextprotocol/sdk/client/auth.js"; -import { - OAuthMetadataSchema, - OAuthProtectedResourceMetadata, -} from "@modelcontextprotocol/sdk/shared/auth.js"; -import { generateOAuthState } from "@/utils/oauthUtils"; - -export interface StateMachineContext { - state: AuthDebuggerState; - serverUrl: string; - provider: DebugInspectorOAuthClientProvider; - updateState: (updates: Partial) => void; -} - -export interface StateTransition { - canTransition: (context: StateMachineContext) => Promise; - execute: (context: StateMachineContext) => Promise; -} - -// State machine transitions -export const oauthTransitions: Record = { - metadata_discovery: { - canTransition: async () => true, - execute: async (context) => { - // Default to discovering from the server's URL - let authServerUrl = new URL("/", context.serverUrl); - let resourceMetadata: OAuthProtectedResourceMetadata | null = null; - let resourceMetadataError: Error | null = null; - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata( - context.serverUrl, - ); - if (resourceMetadata?.authorization_servers?.length) { - authServerUrl = new URL(resourceMetadata.authorization_servers[0]); - } - } catch (e) { - if (e instanceof Error) { - resourceMetadataError = e; - } else { - resourceMetadataError = new Error(String(e)); - } - } - - const resource: URL | undefined = await selectResourceURL( - context.serverUrl, - context.provider, - // we default to null, so swap it for undefined if not set - resourceMetadata ?? undefined, - ); - - const metadata = await discoverAuthorizationServerMetadata(authServerUrl); - if (!metadata) { - throw new Error("Failed to discover OAuth metadata"); - } - const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); - context.provider.saveServerMetadata(parsedMetadata); - context.updateState({ - resourceMetadata, - resource, - resourceMetadataError, - authServerUrl, - oauthMetadata: parsedMetadata, - oauthStep: "client_registration", - }); - }, - }, - - client_registration: { - canTransition: async (context) => !!context.state.oauthMetadata, - execute: async (context) => { - const metadata = context.state.oauthMetadata!; - const clientMetadata = context.provider.clientMetadata; - - // Priority: user-provided scope > discovered scopes - if (!context.provider.scope || context.provider.scope.trim() === "") { - // Prefer scopes from resource metadata if available - const scopesSupported = - context.state.resourceMetadata?.scopes_supported || - metadata.scopes_supported; - // Add all supported scopes to client registration - if (scopesSupported) { - clientMetadata.scope = scopesSupported.join(" "); - } - } - - // Try Static client first, with DCR as fallback - let fullInformation = await context.provider.clientInformation(); - if (!fullInformation) { - fullInformation = await registerClient(context.serverUrl, { - metadata, - clientMetadata, - }); - context.provider.saveClientInformation(fullInformation); - } - - context.updateState({ - oauthClientInfo: fullInformation, - oauthStep: "authorization_redirect", - }); - }, - }, - - authorization_redirect: { - canTransition: async (context) => - !!context.state.oauthMetadata && !!context.state.oauthClientInfo, - execute: async (context) => { - const metadata = context.state.oauthMetadata!; - const clientInformation = context.state.oauthClientInfo!; - - // Priority: user-provided scope > discovered scopes - let scope = context.provider.scope; - if (!scope || scope.trim() === "") { - scope = await discoverScopes( - context.serverUrl, - context.state.resourceMetadata ?? undefined, - ); - } - - const { authorizationUrl, codeVerifier } = await startAuthorization( - context.serverUrl, - { - metadata, - clientInformation, - redirectUrl: context.provider.redirectUrl, - scope, - state: generateOAuthState(), - resource: context.state.resource ?? undefined, - }, - ); - - context.provider.saveCodeVerifier(codeVerifier); - context.updateState({ - authorizationUrl: authorizationUrl, - oauthStep: "authorization_code", - }); - }, - }, - - authorization_code: { - canTransition: async () => true, - execute: async (context) => { - if ( - !context.state.authorizationCode || - context.state.authorizationCode.trim() === "" - ) { - context.updateState({ - validationError: "You need to provide an authorization code", - }); - // Don't advance if no code - throw new Error("Authorization code required"); - } - context.updateState({ - validationError: null, - oauthStep: "token_request", - }); - }, - }, - - token_request: { - canTransition: async (context) => { - return ( - !!context.state.authorizationCode && - !!context.provider.getServerMetadata() && - !!(await context.provider.clientInformation()) - ); - }, - execute: async (context) => { - const codeVerifier = context.provider.codeVerifier(); - const metadata = context.provider.getServerMetadata()!; - const clientInformation = (await context.provider.clientInformation())!; - - const tokens = await exchangeAuthorization(context.serverUrl, { - metadata, - clientInformation, - authorizationCode: context.state.authorizationCode, - codeVerifier, - redirectUri: context.provider.redirectUrl, - resource: context.state.resource - ? context.state.resource instanceof URL - ? context.state.resource - : new URL(context.state.resource) - : undefined, - }); - - context.provider.saveTokens(tokens); - context.updateState({ - oauthTokens: tokens, - oauthStep: "complete", - }); - }, - }, - - complete: { - canTransition: async () => false, - execute: async () => { - // No-op for complete state - }, - }, -}; - -export class OAuthStateMachine { - constructor( - private serverUrl: string, - private updateState: (updates: Partial) => void, - ) {} - - async executeStep(state: AuthDebuggerState): Promise { - const provider = new DebugInspectorOAuthClientProvider(this.serverUrl); - const context: StateMachineContext = { - state, - serverUrl: this.serverUrl, - provider, - updateState: this.updateState, - }; - - const transition = oauthTransitions[state.oauthStep]; - if (!(await transition.canTransition(context))) { - throw new Error(`Cannot transition from ${state.oauthStep}`); - } - - await transition.execute(context); - } -} diff --git a/package-lock.json b/package-lock.json index 0055b4334..ab3bfe74e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -227,6 +227,7 @@ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", @@ -240,7 +241,8 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -273,7 +275,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -819,6 +820,7 @@ } ], "license": "MIT-0", + "peer": true, "engines": { "node": ">=18" } @@ -839,6 +841,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -863,6 +866,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" @@ -1884,6 +1888,7 @@ "integrity": "sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.2.0", "@jest/fake-timers": "30.2.0", @@ -1912,6 +1917,7 @@ "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/fake-timers": "30.2.0", "@jest/types": "30.2.0", @@ -1928,6 +1934,7 @@ "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@sinonjs/fake-timers": "^13.0.0", @@ -1946,6 +1953,7 @@ "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sinclair/typebox": "^0.34.0" }, @@ -1959,6 +1967,7 @@ "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", @@ -1977,7 +1986,8 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinonjs/fake-timers": { "version": "13.0.5", @@ -1985,6 +1995,7 @@ "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@sinonjs/commons": "^3.0.1" } @@ -1995,6 +2006,7 @@ "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", @@ -2007,6 +2019,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2026,6 +2039,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2036,6 +2050,7 @@ "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.2.0", @@ -2057,6 +2072,7 @@ "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -2072,6 +2088,7 @@ "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -2090,6 +2107,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2103,6 +2121,7 @@ "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", @@ -2117,7 +2136,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@jest/expect": { "version": "29.7.0", @@ -2186,6 +2206,7 @@ "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" @@ -2200,6 +2221,7 @@ "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } @@ -3945,7 +3967,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4195,7 +4218,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz", "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4234,7 +4256,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4246,7 +4267,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4387,7 +4407,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -4770,7 +4789,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5243,7 +5261,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6083,7 +6100,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/domexception": { "version": "4.0.0", @@ -6341,7 +6359,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6639,7 +6656,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7733,7 +7749,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8074,6 +8089,7 @@ "integrity": "sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.2.0", "@jest/environment-jsdom-abstract": "30.2.0", @@ -8099,6 +8115,7 @@ "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/fake-timers": "30.2.0", "@jest/types": "30.2.0", @@ -8115,6 +8132,7 @@ "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@sinonjs/fake-timers": "^13.0.0", @@ -8133,6 +8151,7 @@ "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sinclair/typebox": "^0.34.0" }, @@ -8146,6 +8165,7 @@ "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", @@ -8164,7 +8184,8 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { "version": "13.0.5", @@ -8172,6 +8193,7 @@ "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@sinonjs/commons": "^3.0.1" } @@ -8182,6 +8204,7 @@ "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", @@ -8194,6 +8217,7 @@ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 14" } @@ -8204,6 +8228,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -8223,6 +8248,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -8233,6 +8259,7 @@ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" @@ -8247,6 +8274,7 @@ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" @@ -8261,6 +8289,7 @@ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "whatwg-encoding": "^3.1.1" }, @@ -8274,6 +8303,7 @@ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -8288,6 +8318,7 @@ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -8302,6 +8333,7 @@ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -8315,6 +8347,7 @@ "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.2.0", @@ -8336,6 +8369,7 @@ "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -8351,6 +8385,7 @@ "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -8369,6 +8404,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -8409,6 +8445,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8422,6 +8459,7 @@ "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", @@ -8436,7 +8474,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/jest-environment-jsdom/node_modules/tough-cookie": { "version": "5.1.2", @@ -8444,6 +8483,7 @@ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "tldts": "^6.1.32" }, @@ -8457,6 +8497,7 @@ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -8470,6 +8511,7 @@ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -8483,6 +8525,7 @@ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "iconv-lite": "0.6.3" }, @@ -8496,6 +8539,7 @@ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -8506,6 +8550,7 @@ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -8520,6 +8565,7 @@ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18" } @@ -9114,7 +9160,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -9153,7 +9198,6 @@ "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "abab": "^2.0.6", "acorn": "^8.8.1", @@ -9492,6 +9536,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10369,7 +10414,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10545,6 +10589,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10560,6 +10605,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10714,7 +10760,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10727,7 +10772,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10741,7 +10785,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -11149,7 +11194,8 @@ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/run-applescript": { "version": "7.1.0", @@ -11815,7 +11861,6 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -11954,7 +11999,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11978,6 +12022,7 @@ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tldts-core": "^6.1.86" }, @@ -11990,7 +12035,8 @@ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tmpl": { "version": "1.0.5", @@ -12163,7 +12209,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12220,7 +12265,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -12774,7 +12818,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12983,7 +13026,6 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -13077,7 +13119,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13497,7 +13538,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -13585,7 +13625,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }