diff --git a/client/src/components/OAuthFlowTab.tsx b/client/src/components/OAuthFlowTab.tsx index 94cccb2d5..bf9898f8d 100644 --- a/client/src/components/OAuthFlowTab.tsx +++ b/client/src/components/OAuthFlowTab.tsx @@ -25,13 +25,13 @@ import { } from "./ui/select"; import { getStoredTokens } from "../lib/mcp-oauth"; import { ServerWithName } from "../hooks/use-app-state"; +import { EMPTY_OAUTH_FLOW_STATE_V2 } from "../lib/oauth/state-machines/debug-oauth-2025-06-18"; import { - OauthFlowStateJune2025, - EMPTY_OAUTH_FLOW_STATE_V2, + OAuthFlowState, OAuthProtocolVersion, RegistrationStrategy2025_11_25, RegistrationStrategy2025_06_18, -} from "../lib/debug-oauth-state-machine"; +} from "../lib/oauth/state-machines/types"; import { createOAuthStateMachine, getDefaultRegistrationStrategy, @@ -109,7 +109,7 @@ export const OAuthFlowTab = ({ const [authSettings, setAuthSettings] = useState( DEFAULT_AUTH_SETTINGS, ); - const [oauthFlowState, setOAuthFlowState] = useState( + const [oauthFlowState, setOAuthFlowState] = useState( EMPTY_OAUTH_FLOW_STATE_V2, ); @@ -222,7 +222,7 @@ export const OAuthFlowTab = ({ }, []); const updateOAuthFlowState = useCallback( - (updates: Partial) => { + (updates: Partial) => { setOAuthFlowState((prev) => ({ ...prev, ...updates })); }, [], diff --git a/client/src/components/OAuthSequenceDiagram.tsx b/client/src/components/OAuthSequenceDiagram.tsx index 20c24f3ee..2ae89980e 100644 --- a/client/src/components/OAuthSequenceDiagram.tsx +++ b/client/src/components/OAuthSequenceDiagram.tsx @@ -1,1054 +1,63 @@ -import { useMemo, memo, useEffect } from "react"; -import type { ReactNode } from "react"; -import { - Background, - Controls, - Edge, - EdgeProps, - Handle, - Node, - NodeProps, - Position, - ReactFlow, - EdgeLabelRenderer, - BaseEdge, - useReactFlow, - ReactFlowProvider, -} from "@xyflow/react"; -import "@xyflow/react/dist/style.css"; -import { cn } from "@/lib/utils"; -import { - OauthFlowStateJune2025, - OAuthFlowStep, +import { memo, useMemo } from "react"; +import type { OAuthProtocolVersion, -} from "@/lib/debug-oauth-state-machine"; - -type NodeStatus = "complete" | "current" | "pending"; - -// Actor/Swimlane node types -interface ActorNodeData extends Record { - label: string; - color: string; - totalHeight: number; // Total height for alignment - segments: Array<{ - id: string; - type: "box" | "line"; - height: number; - handleId?: string; - }>; -} - -// Edge data for action labels -interface ActionEdgeData extends Record { - label: string; - description: string; - status: NodeStatus; - details?: Array<{ label: string; value: ReactNode }>; -} - -// Actor configuration -const ACTORS = { - client: { label: "Client", color: "#10b981" }, // Green - browser: { label: "User-Agent (Browser)", color: "#8b5cf6" }, // Purple - mcpServer: { label: "MCP Server (Resource Server)", color: "#f59e0b" }, // Orange - authServer: { label: "Authorization Server", color: "#3b82f6" }, // Blue -}; - -// Layout constants -const ACTOR_X_POSITIONS = { - browser: 100, - client: 350, - mcpServer: 650, - authServer: 950, -}; -const ACTION_SPACING = 180; // Vertical space between actions -const START_Y = 120; // Initial Y position for first action -const SEGMENT_HEIGHT = 80; // Height of each segment - -// Actor Node - Segmented vertical swimlane -const ActorNode = memo((props: NodeProps>) => { - const { data } = props; - let currentY = 50; - - return ( -
- {/* Actor label at top - fixed height for consistent alignment */} -
- {data.label} -
- - {/* Segmented vertical line */} -
- {data.segments.map((segment) => { - const segmentY = currentY; - currentY += segment.height; - - if (segment.type === "box") { - return ( -
- {segment.handleId && ( - <> - {/* Right side handles - both source and target for bidirectional flow */} - - - {/* Left side handles - both source and target for bidirectional flow */} - - - - )} -
- ); - } else { - return ( -
- ); - } - })} -
- - {/* Actor label at bottom - fixed height for consistent alignment */} -
- {data.label} -
-
- ); -}); - -ActorNode.displayName = "ActorNode"; - -// Custom Edge with Label -const CustomActionEdge = memo((props: EdgeProps>) => { - const { sourceX, sourceY, targetX, targetY, data, style, markerEnd } = props; - - if (!data) return null; - - const statusColor = { - complete: "border-green-500/50 bg-green-50 dark:bg-green-950/20", - current: - "border-blue-500 bg-blue-100 dark:bg-blue-950/30 shadow-lg shadow-blue-500/20 animate-pulse", - pending: "border-border bg-muted/30", - }[data.status]; - - const labelX = (sourceX + targetX) / 2; - const labelY = (sourceY + targetY) / 2; - - return ( - <> - - -
-
-
{data.label}
- {data.details && data.details.length > 0 && ( -
- {data.details.map((d, i) => ( -
- {d.label}: {d.value} -
- ))} -
- )} -
-
-
- - ); -}); - -CustomActionEdge.displayName = "CustomActionEdge"; - -const nodeTypes = { - actor: ActorNode, -}; - -const edgeTypes = { - actionEdge: CustomActionEdge, -}; + OAuthFlowState, +} from "@/lib/oauth/state-machines/types"; +import { OAuthSequenceDiagramContent } from "./oauth/shared/OAuthSequenceDiagramContent"; +import { buildActions_2025_11_25 } from "@/lib/oauth/state-machines/debug-oauth-2025-11-25"; +import { buildActions_2025_06_18 } from "@/lib/oauth/state-machines/debug-oauth-2025-06-18"; +import { buildActions_2025_03_26 } from "@/lib/oauth/state-machines/debug-oauth-2025-03-26"; interface OAuthSequenceDiagramProps { - flowState: OauthFlowStateJune2025; + flowState: OAuthFlowState; registrationStrategy?: "cimd" | "dcr" | "preregistered"; protocolVersion?: OAuthProtocolVersion; } -// Helper to determine status based on current step and actual action order -const getActionStatus = ( - actionStep: OAuthFlowStep | string, - currentStep: OAuthFlowStep, - actionsInFlow: Array<{ id: string }>, -): NodeStatus => { - // Find indices in the actual flow (not a hardcoded order) - const actionIndex = actionsInFlow.findIndex((a) => a.id === actionStep); - const currentIndex = actionsInFlow.findIndex((a) => a.id === currentStep); - - // If step not found in flow, it's pending - if (actionIndex === -1) return "pending"; - - // Show completed steps (everything up to and including current) - if (actionIndex <= currentIndex) return "complete"; - // Show the NEXT step as current (what will happen when you click Next Step) - if (actionIndex === currentIndex + 1) return "current"; - return "pending"; -}; - -// Internal component that has access to ReactFlow instance -const DiagramContent = memo( - ({ +/** + * Factory component that selects the appropriate OAuth actions builder + * based on the protocol version and renders the sequence diagram. + * + * Actions are co-located with their state machine files for easy maintenance + * and to ensure step IDs match between business logic and visualization. + */ +export const OAuthSequenceDiagram = memo((props: OAuthSequenceDiagramProps) => { + const { flowState, - registrationStrategy = "cimd", + registrationStrategy = "dcr", protocolVersion = "2025-11-25", - }: OAuthSequenceDiagramProps) => { - const reactFlowInstance = useReactFlow(); + } = props; + + // Select the appropriate actions builder based on protocol version + const actions = useMemo(() => { + switch (protocolVersion) { + case "2025-11-25": + return buildActions_2025_11_25(flowState, registrationStrategy); + + case "2025-06-18": + // 2025-06-18 doesn't support CIMD, fallback to DCR + return buildActions_2025_06_18( + flowState, + registrationStrategy === "cimd" ? "dcr" : registrationStrategy, + ); + + case "2025-03-26": + // 2025-03-26 doesn't support CIMD, fallback to DCR + return buildActions_2025_03_26( + flowState, + registrationStrategy === "cimd" ? "dcr" : registrationStrategy, + ); + + default: + console.warn( + `Unknown protocol version: ${protocolVersion}. Defaulting to 2025-11-25.`, + ); + return buildActions_2025_11_25(flowState, registrationStrategy); + } + }, [protocolVersion, flowState, registrationStrategy]); - const { nodes, edges } = useMemo(() => { - const currentStep = flowState.currentStep; - - // Define actions in the sequence (matches MCP OAuth spec) - const actions = [ - // For 2025-03-26: Start directly with discovery (no initial MCP request) - // Other protocols: Show initial unauthorized request that triggers OAuth - ...(protocolVersion === "2025-03-26" - ? [] - : [ - { - id: "request_without_token", - label: "MCP request without token", - description: - "Client makes initial request without authorization", - from: "client", - to: "mcpServer", - details: flowState.serverUrl - ? [ - { label: "POST", value: flowState.serverUrl }, - { label: "method", value: "initialize" }, - ] - : undefined, - }, - ]), - // For 2025-03-26: Skip RFC9728 steps, go directly to auth server discovery - ...(protocolVersion === "2025-03-26" - ? [] - : [ - { - id: "received_401_unauthorized", - label: "HTTP 401 Unauthorized with WWW-Authenticate header", - description: "Server returns 401 with WWW-Authenticate header", - from: "mcpServer", - to: "client", - details: flowState.resourceMetadataUrl - ? [{ label: "Note", value: "Extract resource_metadata URL" }] - : undefined, - }, - { - id: "request_resource_metadata", - label: "Request Protected Resource Metadata", - description: "Client requests metadata from well-known URI", - from: "client", - to: "mcpServer", - details: flowState.resourceMetadataUrl - ? [ - { - label: "GET", - value: new URL(flowState.resourceMetadataUrl).pathname, - }, - ] - : undefined, - }, - { - id: "received_resource_metadata", - label: "Return metadata", - description: "Server returns OAuth protected resource metadata", - from: "mcpServer", - to: "client", - details: flowState.resourceMetadata?.authorization_servers - ? [ - { - label: "Auth Server", - value: - flowState.resourceMetadata.authorization_servers[0], - }, - ] - : undefined, - }, - ]), - { - id: "request_authorization_server_metadata", - label: - protocolVersion === "2025-03-26" - ? "GET /.well-known/oauth-authorization-server from MCP base URL" - : protocolVersion === "2025-11-25" - ? "GET Authorization server metadata endpoint" - : "GET /.well-known/oauth-authorization-server", - description: - protocolVersion === "2025-03-26" - ? "Direct discovery from MCP server base URL with fallback to /authorize, /token, /register" - : protocolVersion === "2025-11-25" - ? "Try OAuth path insertion, OIDC path insertion, OIDC path appending" - : "Try RFC8414 path, then RFC8414 root (no OIDC support)", - from: "client", - to: "authServer", - details: flowState.authorizationServerUrl - ? [ - { label: "URL", value: flowState.authorizationServerUrl }, - { label: "Protocol", value: protocolVersion }, - ] - : undefined, - }, - { - id: "received_authorization_server_metadata", - label: "Authorization server metadata response", - description: "Authorization Server returns metadata", - from: "authServer", - to: "client", - details: flowState.authorizationServerMetadata - ? [ - { - label: "Token", - value: new URL( - flowState.authorizationServerMetadata.token_endpoint, - ).pathname, - }, - { - label: "Auth", - value: new URL( - flowState.authorizationServerMetadata.authorization_endpoint, - ).pathname, - }, - ] - : undefined, - }, - // Client registration steps - conditionally included based on strategy - ...(registrationStrategy === "cimd" - ? [ - { - id: "cimd_prepare", - label: "Client uses HTTPS URL as client_id", - description: - "Client prepares to use URL-based client identification", - from: "client", - to: "client", - details: flowState.clientId - ? [ - { - label: "client_id (URL)", - value: flowState.clientId.includes("http") - ? flowState.clientId - : "https://www.mcpjam.com/.well-known/oauth/client-metadata.json", - }, - { - label: "Method", - value: "Client ID Metadata Document (CIMD)", - }, - ] - : [ - { - label: "Note", - value: "HTTPS URL points to metadata document", - }, - ], - }, - { - id: "cimd_fetch_request", - label: "Fetch metadata from client_id URL", - description: - "Authorization Server fetches client metadata from the URL", - from: "authServer", - to: "client", - details: [ - { - label: "Action", - value: "GET client_id URL", - }, - { - label: "Note", - value: - "Server initiates metadata fetch during authorization", - }, - ], - }, - { - id: "cimd_metadata_response", - label: "JSON metadata document", - description: - "Client hosting returns metadata with redirect_uris and client info", - from: "client", - to: "authServer", - details: [ - { - label: "Content-Type", - value: "application/json", - }, - { - label: "Contains", - value: "client_id, client_name, redirect_uris, etc.", - }, - ], - }, - { - id: "received_client_credentials", - label: "Validate metadata and redirect_uris", - description: "Authorization Server validates fetched metadata", - from: "authServer", - to: "authServer", - details: [ - { - label: "Validates", - value: "client_id matches URL, redirect_uris are valid", - }, - { - label: "Security", - value: "SSRF protection, domain trust policies", - }, - ], - }, - ] - : registrationStrategy === "dcr" - ? [ - { - id: "request_client_registration", - label: `POST /register (${protocolVersion})`, - description: - "Client registers dynamically with Authorization Server", - from: "client", - to: "authServer", - details: [ - { - label: "Note", - value: "Dynamic client registration (DCR)", - }, - ], - }, - { - id: "received_client_credentials", - label: "Client Credentials", - description: - "Authorization Server returns client ID and credentials", - from: "authServer", - to: "client", - details: flowState.clientId - ? [ - { - label: "client_id", - value: flowState.clientId.substring(0, 20) + "...", - }, - ] - : undefined, - }, - ] - : [ - { - id: "received_client_credentials", - label: `Use Pre-registered Client (${protocolVersion})`, - description: - "Client uses pre-configured credentials (skipped DCR)", - from: "client", - to: "client", - details: flowState.clientId - ? [ - { - label: "client_id", - value: flowState.clientId.substring(0, 20) + "...", - }, - { - label: "Note", - value: "Pre-registered (no DCR needed)", - }, - ] - : [ - { - label: "Note", - value: "Pre-registered client credentials", - }, - ], - }, - ]), - { - id: "generate_pkce_parameters", - label: - protocolVersion === "2025-11-25" - ? "Generate PKCE (REQUIRED)\nInclude resource parameter" - : protocolVersion === "2025-03-26" - ? "Generate PKCE (REQUIRED)\nInclude resource parameter" - : "Generate PKCE parameters", - description: - protocolVersion === "2025-11-25" - ? "Client generates code verifier and challenge (REQUIRED), includes resource parameter" - : protocolVersion === "2025-03-26" - ? "Client generates code verifier and challenge (REQUIRED), includes resource parameter" - : "Client generates code verifier and challenge (recommended), includes resource parameter", - from: "client", - to: "client", - details: flowState.codeChallenge - ? [ - { - label: "code_challenge", - value: flowState.codeChallenge.substring(0, 15) + "...", - }, - { - label: "method", - value: flowState.codeChallengeMethod || "S256", - }, - { label: "resource", value: flowState.serverUrl || "—" }, - { label: "Protocol", value: protocolVersion }, - ] - : undefined, - }, - { - id: "authorization_request", - label: "Open browser with authorization URL", - description: - "Client opens browser with authorization URL + code_challenge + resource", - from: "client", - to: "browser", - details: flowState.authorizationUrl - ? [ - { - label: "code_challenge", - value: - flowState.codeChallenge?.substring(0, 12) + "..." || "S256", - }, - { label: "resource", value: flowState.serverUrl || "" }, - ] - : undefined, - }, - { - id: "browser_to_auth_server", - label: "Authorization request with resource parameter", - description: "Browser navigates to authorization endpoint", - from: "browser", - to: "authServer", - details: flowState.authorizationUrl - ? [{ label: "Note", value: "User authorizes in browser" }] - : undefined, - }, - { - id: "auth_redirect_to_browser", - label: "Redirect to callback with authorization code", - description: - "Authorization Server redirects browser back to callback URL", - from: "authServer", - to: "browser", - details: flowState.authorizationCode - ? [ - { - label: "code", - value: flowState.authorizationCode.substring(0, 20) + "...", - }, - ] - : undefined, - }, - { - id: "received_authorization_code", - label: "Authorization code callback", - description: - "Browser redirects back to client with authorization code", - from: "browser", - to: "client", - details: flowState.authorizationCode - ? [ - { - label: "code", - value: flowState.authorizationCode.substring(0, 20) + "...", - }, - ] - : undefined, - }, - { - id: "token_request", - label: "Token request + code_verifier + resource", - description: "Client exchanges authorization code for access token", - from: "client", - to: "authServer", - details: flowState.codeVerifier - ? [ - { label: "grant_type", value: "authorization_code" }, - { label: "resource", value: flowState.serverUrl || "" }, - ] - : undefined, - }, - { - id: "received_access_token", - label: "Access token (+ refresh token)", - description: "Authorization Server returns access token", - from: "authServer", - to: "client", - details: flowState.accessToken - ? [ - { label: "token_type", value: flowState.tokenType || "Bearer" }, - { - label: "expires_in", - value: flowState.expiresIn?.toString() || "3600", - }, - ] - : undefined, - }, - { - id: "authenticated_mcp_request", - label: "MCP request with access token", - description: "Client makes authenticated request to MCP server", - from: "client", - to: "mcpServer", - details: flowState.accessToken - ? [ - { label: "POST", value: "tools/list" }, - { - label: "Authorization", - value: - "Bearer " + flowState.accessToken.substring(0, 15) + "...", - }, - ] - : undefined, - }, - { - id: "complete", - label: "MCP response", - description: "MCP Server returns successful response", - from: "mcpServer", - to: "client", - details: flowState.accessToken - ? [ - { label: "Status", value: "200 OK" }, - { label: "Content", value: "tools, resources, prompts" }, - ] - : undefined, - }, - ]; - - // Calculate total height needed for segments - const totalActions = actions.length; - // Total segment height: space for all actions + a final buffer - const totalSegmentHeight = totalActions * ACTION_SPACING + 100; - - // Create segments for each actor (order: Browser, Client, MCP Server, Auth Server) - const browserSegments: ActorNodeData["segments"] = []; - const clientSegments: ActorNodeData["segments"] = []; - const mcpServerSegments: ActorNodeData["segments"] = []; - const authServerSegments: ActorNodeData["segments"] = []; - - let currentY = 0; - - actions.forEach((action, index) => { - const actionY = index * ACTION_SPACING; - - // Add line segments before the action - if (currentY < actionY) { - browserSegments.push({ - id: `browser-line-${index}`, - type: "line", - height: actionY - currentY, - }); - clientSegments.push({ - id: `client-line-${index}`, - type: "line", - height: actionY - currentY, - }); - mcpServerSegments.push({ - id: `mcp-line-${index}`, - type: "line", - height: actionY - currentY, - }); - authServerSegments.push({ - id: `auth-line-${index}`, - type: "line", - height: actionY - currentY, - }); - currentY = actionY; - } - - // Add box segments for the actors involved in this action - if (action.from === "browser" || action.to === "browser") { - browserSegments.push({ - id: `browser-box-${action.id}`, - type: "box", - height: SEGMENT_HEIGHT, - handleId: action.id, - }); - } else { - browserSegments.push({ - id: `browser-line-action-${index}`, - type: "line", - height: SEGMENT_HEIGHT, - }); - } - - if (action.from === "client" || action.to === "client") { - clientSegments.push({ - id: `client-box-${action.id}`, - type: "box", - height: SEGMENT_HEIGHT, - handleId: action.id, - }); - } else { - clientSegments.push({ - id: `client-line-action-${index}`, - type: "line", - height: SEGMENT_HEIGHT, - }); - } - - if (action.from === "mcpServer" || action.to === "mcpServer") { - mcpServerSegments.push({ - id: `mcp-box-${action.id}`, - type: "box", - height: SEGMENT_HEIGHT, - handleId: action.id, - }); - } else { - mcpServerSegments.push({ - id: `mcp-line-action-${index}`, - type: "line", - height: SEGMENT_HEIGHT, - }); - } - - if (action.from === "authServer" || action.to === "authServer") { - authServerSegments.push({ - id: `auth-box-${action.id}`, - type: "box", - height: SEGMENT_HEIGHT, - handleId: action.id, - }); - } else { - authServerSegments.push({ - id: `auth-line-action-${index}`, - type: "line", - height: SEGMENT_HEIGHT, - }); - } - - currentY += SEGMENT_HEIGHT; - }); - - // Add final line segments to reach the same total height for all actors - const remainingHeight = totalSegmentHeight - currentY; - if (remainingHeight > 0) { - browserSegments.push({ - id: "browser-line-end", - type: "line", - height: remainingHeight, - }); - clientSegments.push({ - id: "client-line-end", - type: "line", - height: remainingHeight, - }); - mcpServerSegments.push({ - id: "mcp-line-end", - type: "line", - height: remainingHeight, - }); - authServerSegments.push({ - id: "auth-line-end", - type: "line", - height: remainingHeight, - }); - } - - // Create actor nodes (left to right: Browser, Client, MCP Server, Auth Server) - const nodes: Node[] = [ - { - id: "actor-browser", - type: "actor", - position: { x: ACTOR_X_POSITIONS.browser, y: 0 }, - data: { - label: ACTORS.browser.label, - color: ACTORS.browser.color, - totalHeight: totalSegmentHeight, - segments: browserSegments, - }, - draggable: false, - }, - { - id: "actor-client", - type: "actor", - position: { x: ACTOR_X_POSITIONS.client, y: 0 }, - data: { - label: ACTORS.client.label, - color: ACTORS.client.color, - totalHeight: totalSegmentHeight, - segments: clientSegments, - }, - draggable: false, - }, - { - id: "actor-mcpServer", - type: "actor", - position: { x: ACTOR_X_POSITIONS.mcpServer, y: 0 }, - data: { - label: ACTORS.mcpServer.label, - color: ACTORS.mcpServer.color, - totalHeight: totalSegmentHeight, - segments: mcpServerSegments, - }, - draggable: false, - }, - { - id: "actor-authServer", - type: "actor", - position: { x: ACTOR_X_POSITIONS.authServer, y: 0 }, - data: { - label: ACTORS.authServer.label, - color: ACTORS.authServer.color, - totalHeight: totalSegmentHeight, - segments: authServerSegments, - }, - draggable: false, - }, - ]; - - // Create action edges - const edges: Edge[] = actions.map((action, index) => { - const status = getActionStatus(action.id, currentStep, actions); - const isComplete = status === "complete"; - const isCurrent = status === "current"; - const isPending = status === "pending"; - - // Determine arrow color based on status - const arrowColor = isComplete - ? "#10b981" - : isCurrent - ? "#3b82f6" - : "#d1d5db"; - - // Determine handle positions based on flow direction - const sourceX = - ACTOR_X_POSITIONS[action.from as keyof typeof ACTOR_X_POSITIONS]; - const targetX = - ACTOR_X_POSITIONS[action.to as keyof typeof ACTOR_X_POSITIONS]; - const isLeftToRight = sourceX < targetX; - - return { - id: `edge-${action.id}`, - source: `actor-${action.from}`, - target: `actor-${action.to}`, - sourceHandle: isLeftToRight - ? `${action.id}-right-source` - : `${action.id}-left-source`, - targetHandle: isLeftToRight - ? `${action.id}-left-target` - : `${action.id}-right-target`, - type: "actionEdge", - data: { - label: action.label, - description: action.description, - status, - details: action.details, - }, - animated: isCurrent, // Only animate current step - markerEnd: { - type: "arrowclosed" as const, - color: arrowColor, - width: 12, - height: 12, - }, - style: { - stroke: arrowColor, - strokeWidth: isCurrent ? 3 : isComplete ? 2 : 1.5, - strokeDasharray: isCurrent ? "5,5" : undefined, // Only current step is dashed - opacity: isPending ? 0.4 : 1, // Dim pending edges - }, - }; - }); - - return { nodes, edges }; - }, [flowState, registrationStrategy, protocolVersion]); - - // Auto-zoom to current step - useEffect(() => { - if (!reactFlowInstance || !flowState.currentStep) { - return; - } - - // Small delay to ensure nodes are rendered - const timer = setTimeout(() => { - // If reset to idle, zoom back to the top - if (flowState.currentStep === "idle") { - // Zoom to the top of the diagram - // Center around the middle actors (Client and MCP Server) - reactFlowInstance.setCenter(550, 200, { - zoom: 0.8, - duration: 800, - }); - return; - } - - // Don't zoom when flow is complete - let user stay at current position - if (flowState.currentStep === "complete") { - return; - } - - // Find the edge that has "current" status (the next step to execute) - const currentEdge = edges.find((e) => e.data?.status === "current"); - - if (currentEdge) { - // Get source and target actor positions - const sourceNode = nodes.find((n) => n.id === currentEdge.source); - const targetNode = nodes.find((n) => n.id === currentEdge.target); - - if (sourceNode && targetNode) { - // Find the action index to calculate Y position - const actionIndex = edges.findIndex((e) => e.id === currentEdge.id); - - // Calculate positions - // Actor nodes have a header (~52px) + some padding (~50px) - // Each action segment is ACTION_SPACING (180) apart - const headerOffset = 102; - const actionY = headerOffset + actionIndex * 180 + 40; // 40 is half of SEGMENT_HEIGHT - const centerX = - (sourceNode.position.x + targetNode.position.x) / 2 + 70; // +70 to account for node width - const centerY = actionY; - - // Zoom into the current step with animation - reactFlowInstance.setCenter(centerX, centerY, { - zoom: 1.2, - duration: 800, - }); - } - } - }, 100); - - return () => clearTimeout(timer); - }, [flowState.currentStep, edges, nodes, reactFlowInstance]); - - return ( -
- - - - -
- ); - }, -); - -DiagramContent.displayName = "DiagramContent"; - -// Wrapper component with ReactFlowProvider -export const OAuthSequenceDiagram = memo((props: OAuthSequenceDiagramProps) => { return ( - - - + ); }); diff --git a/client/src/components/oauth/ProtocolVersionSelector.tsx b/client/src/components/oauth/ProtocolVersionSelector.tsx index 3a8347d67..cb501604e 100644 --- a/client/src/components/oauth/ProtocolVersionSelector.tsx +++ b/client/src/components/oauth/ProtocolVersionSelector.tsx @@ -28,11 +28,11 @@ import { } from "@/components/ui/collapsible"; import { Button } from "@/components/ui/button"; import { useState } from "react"; +import { OAuthProtocolVersion } from "@/lib/oauth/state-machines/types"; import { - OAuthProtocolVersion, PROTOCOL_VERSION_INFO, getSupportedRegistrationStrategies, -} from "@/lib/debug-oauth-state-machine"; +} from "@/lib/oauth/state-machines/factory"; interface ProtocolVersionSelectorProps { value: OAuthProtocolVersion; diff --git a/client/src/components/oauth/shared/ActorNode.tsx b/client/src/components/oauth/shared/ActorNode.tsx new file mode 100644 index 000000000..aa9dcdaa4 --- /dev/null +++ b/client/src/components/oauth/shared/ActorNode.tsx @@ -0,0 +1,144 @@ +import { memo } from "react"; +import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; +import { cn } from "@/lib/utils"; +import type { ActorNodeData } from "./types"; + +// Actor Node - Segmented vertical swimlane +export const ActorNode = memo((props: NodeProps>) => { + const { data } = props; + let currentY = 50; + + return ( +
+ {/* Actor label at top - fixed height for consistent alignment */} +
+ {data.label} +
+ + {/* Segmented vertical line */} +
+ {data.segments.map((segment) => { + const segmentY = currentY; + currentY += segment.height; + + if (segment.type === "box") { + return ( +
+ {segment.handleId && ( + <> + {/* Right side handles - both source and target for bidirectional flow */} + + + {/* Left side handles - both source and target for bidirectional flow */} + + + + )} +
+ ); + } else { + return ( +
+ ); + } + })} +
+ + {/* Actor label at bottom - fixed height for consistent alignment */} +
+ {data.label} +
+
+ ); +}); + +ActorNode.displayName = "ActorNode"; diff --git a/client/src/components/oauth/shared/CustomActionEdge.tsx b/client/src/components/oauth/shared/CustomActionEdge.tsx new file mode 100644 index 000000000..2025003f9 --- /dev/null +++ b/client/src/components/oauth/shared/CustomActionEdge.tsx @@ -0,0 +1,68 @@ +import { memo } from "react"; +import { + BaseEdge, + EdgeLabelRenderer, + type Edge, + type EdgeProps, +} from "@xyflow/react"; +import { cn } from "@/lib/utils"; +import type { ActionEdgeData } from "./types"; + +// Custom Edge with Label +export const CustomActionEdge = memo( + (props: EdgeProps>) => { + const { sourceX, sourceY, targetX, targetY, data, style, markerEnd } = + props; + + if (!data) return null; + + const statusColor = { + complete: "border-green-500/50 bg-green-50 dark:bg-green-950/20", + current: + "border-blue-500 bg-blue-100 dark:bg-blue-950/30 shadow-lg shadow-blue-500/20 animate-pulse", + pending: "border-border bg-muted/30", + }[data.status]; + + const labelX = (sourceX + targetX) / 2; + const labelY = (sourceY + targetY) / 2; + + return ( + <> + + +
+
+
{data.label}
+ {data.details && data.details.length > 0 && ( +
+ {data.details.map((d, i) => ( +
+ {d.label}: {d.value} +
+ ))} +
+ )} +
+
+
+ + ); + }, +); + +CustomActionEdge.displayName = "CustomActionEdge"; diff --git a/client/src/components/oauth/shared/DiagramLayout.tsx b/client/src/components/oauth/shared/DiagramLayout.tsx new file mode 100644 index 000000000..2d1da3221 --- /dev/null +++ b/client/src/components/oauth/shared/DiagramLayout.tsx @@ -0,0 +1,117 @@ +import { useEffect } from "react"; +import { + Background, + Controls, + ReactFlow, + useReactFlow, + type Edge, + type Node, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { ActorNode } from "./ActorNode"; +import { CustomActionEdge } from "./CustomActionEdge"; +import type { OAuthFlowStep } from "@/lib/oauth/state-machines/types"; + +const nodeTypes = { + actor: ActorNode, +}; + +const edgeTypes = { + actionEdge: CustomActionEdge, +}; + +interface DiagramLayoutProps { + nodes: Node[]; + edges: Edge[]; + currentStep: OAuthFlowStep; +} + +export const DiagramLayout = ({ + nodes, + edges, + currentStep, +}: DiagramLayoutProps) => { + const reactFlowInstance = useReactFlow(); + + // Auto-zoom to current step + useEffect(() => { + if (!reactFlowInstance || !currentStep) { + return; + } + + // Small delay to ensure nodes are rendered + const timer = setTimeout(() => { + // If reset to idle, zoom back to the top + if (currentStep === "idle") { + // Zoom to the top of the diagram + // Center around the middle actors (Client and MCP Server) + reactFlowInstance.setCenter(550, 200, { + zoom: 0.8, + duration: 800, + }); + return; + } + + // Don't zoom when flow is complete - let user stay at current position + if (currentStep === "complete") { + return; + } + + // Find the edge that has "current" status (the next step to execute) + const currentEdge = edges.find((e) => e.data?.status === "current"); + + if (currentEdge) { + // Get source and target actor positions + const sourceNode = nodes.find((n) => n.id === currentEdge.source); + const targetNode = nodes.find((n) => n.id === currentEdge.target); + + if (sourceNode && targetNode) { + // Find the action index to calculate Y position + const actionIndex = edges.findIndex((e) => e.id === currentEdge.id); + + // Calculate positions + // Actor nodes have a header (~52px) + some padding (~50px) + // Each action segment is ACTION_SPACING (180) apart + const headerOffset = 102; + const actionY = headerOffset + actionIndex * 180 + 40; // 40 is half of SEGMENT_HEIGHT + const centerX = + (sourceNode.position.x + targetNode.position.x) / 2 + 70; // +70 to account for node width + const centerY = actionY; + + // Zoom into the current step with animation + reactFlowInstance.setCenter(centerX, centerY, { + zoom: 1.2, + duration: 800, + }); + } + } + }, 100); + + return () => clearTimeout(timer); + }, [currentStep, edges, nodes, reactFlowInstance]); + + return ( +
+ + + + +
+ ); +}; diff --git a/client/src/components/oauth/shared/OAuthSequenceDiagramContent.tsx b/client/src/components/oauth/shared/OAuthSequenceDiagramContent.tsx new file mode 100644 index 000000000..b8f7d8d23 --- /dev/null +++ b/client/src/components/oauth/shared/OAuthSequenceDiagramContent.tsx @@ -0,0 +1,39 @@ +import { useMemo, memo } from "react"; +import { ReactFlowProvider } from "@xyflow/react"; +import type { OAuthFlowState } from "@/lib/oauth/state-machines/types"; +import { DiagramLayout, buildNodesAndEdges, type Action } from "./index"; + +interface OAuthSequenceDiagramContentProps { + flowState: OAuthFlowState; + actions: Action[]; +} + +const DiagramContent = memo( + ({ flowState, actions }: OAuthSequenceDiagramContentProps) => { + const { nodes, edges } = useMemo(() => { + return buildNodesAndEdges(actions, flowState.currentStep); + }, [actions, flowState.currentStep]); + + return ( + + ); + }, +); + +DiagramContent.displayName = "OAuthDiagramContent"; + +export const OAuthSequenceDiagramContent = memo( + (props: OAuthSequenceDiagramContentProps) => { + return ( + + + + ); + }, +); + +OAuthSequenceDiagramContent.displayName = "OAuthSequenceDiagramContent"; diff --git a/client/src/components/oauth/shared/constants.ts b/client/src/components/oauth/shared/constants.ts new file mode 100644 index 000000000..8b2ee1c7e --- /dev/null +++ b/client/src/components/oauth/shared/constants.ts @@ -0,0 +1,19 @@ +// Actor configuration +export const ACTORS = { + client: { label: "Client", color: "#10b981" }, // Green + browser: { label: "User-Agent (Browser)", color: "#8b5cf6" }, // Purple + mcpServer: { label: "MCP Server (Resource Server)", color: "#f59e0b" }, // Orange + authServer: { label: "Authorization Server", color: "#3b82f6" }, // Blue +}; + +// Layout constants +export const ACTOR_X_POSITIONS = { + browser: 100, + client: 350, + mcpServer: 650, + authServer: 950, +}; + +export const ACTION_SPACING = 180; // Vertical space between actions +export const START_Y = 120; // Initial Y position for first action +export const SEGMENT_HEIGHT = 80; // Height of each segment diff --git a/client/src/components/oauth/shared/diagramBuilder.ts b/client/src/components/oauth/shared/diagramBuilder.ts new file mode 100644 index 000000000..c8facb9fa --- /dev/null +++ b/client/src/components/oauth/shared/diagramBuilder.ts @@ -0,0 +1,215 @@ +import type { Node, Edge } from "@xyflow/react"; +import type { OAuthFlowStep } from "@/lib/oauth/state-machines/types"; +import { + ACTORS, + ACTOR_X_POSITIONS, + ACTION_SPACING, + SEGMENT_HEIGHT, +} from "./constants"; +import { getActionStatus } from "./utils"; +import type { Action, ActorNodeData } from "./types"; + +export function buildNodesAndEdges( + actions: Action[], + currentStep: OAuthFlowStep, +): { nodes: Node[]; edges: Edge[] } { + const totalActions = actions.length; + const totalSegmentHeight = totalActions * ACTION_SPACING + 100; + + // Create segments for each actor + const browserSegments: ActorNodeData["segments"] = []; + const clientSegments: ActorNodeData["segments"] = []; + const mcpServerSegments: ActorNodeData["segments"] = []; + const authServerSegments: ActorNodeData["segments"] = []; + + let currentY = 0; + + actions.forEach((action, index) => { + const actionY = index * ACTION_SPACING; + + // Add line segments before the action + if (currentY < actionY) { + const lineHeight = actionY - currentY; + browserSegments.push({ + id: `browser-line-${index}`, + type: "line", + height: lineHeight, + }); + clientSegments.push({ + id: `client-line-${index}`, + type: "line", + height: lineHeight, + }); + mcpServerSegments.push({ + id: `mcp-line-${index}`, + type: "line", + height: lineHeight, + }); + authServerSegments.push({ + id: `auth-line-${index}`, + type: "line", + height: lineHeight, + }); + currentY = actionY; + } + + // Add box segments for the actors involved in this action + const addSegmentForActor = ( + actorName: string, + segments: ActorNodeData["segments"], + ) => { + if (action.from === actorName || action.to === actorName) { + segments.push({ + id: `${actorName}-box-${action.id}`, + type: "box", + height: SEGMENT_HEIGHT, + handleId: action.id, + }); + } else { + segments.push({ + id: `${actorName}-line-action-${index}`, + type: "line", + height: SEGMENT_HEIGHT, + }); + } + }; + + addSegmentForActor("browser", browserSegments); + addSegmentForActor("client", clientSegments); + addSegmentForActor("mcpServer", mcpServerSegments); + addSegmentForActor("authServer", authServerSegments); + + currentY += SEGMENT_HEIGHT; + }); + + // Add final line segments + const remainingHeight = totalSegmentHeight - currentY; + if (remainingHeight > 0) { + browserSegments.push({ + id: "browser-line-end", + type: "line", + height: remainingHeight, + }); + clientSegments.push({ + id: "client-line-end", + type: "line", + height: remainingHeight, + }); + mcpServerSegments.push({ + id: "mcp-line-end", + type: "line", + height: remainingHeight, + }); + authServerSegments.push({ + id: "auth-line-end", + type: "line", + height: remainingHeight, + }); + } + + // Create actor nodes + const nodes: Node[] = [ + { + id: "actor-browser", + type: "actor", + position: { x: ACTOR_X_POSITIONS.browser, y: 0 }, + data: { + label: ACTORS.browser.label, + color: ACTORS.browser.color, + totalHeight: totalSegmentHeight, + segments: browserSegments, + }, + draggable: false, + }, + { + id: "actor-client", + type: "actor", + position: { x: ACTOR_X_POSITIONS.client, y: 0 }, + data: { + label: ACTORS.client.label, + color: ACTORS.client.color, + totalHeight: totalSegmentHeight, + segments: clientSegments, + }, + draggable: false, + }, + { + id: "actor-mcpServer", + type: "actor", + position: { x: ACTOR_X_POSITIONS.mcpServer, y: 0 }, + data: { + label: ACTORS.mcpServer.label, + color: ACTORS.mcpServer.color, + totalHeight: totalSegmentHeight, + segments: mcpServerSegments, + }, + draggable: false, + }, + { + id: "actor-authServer", + type: "actor", + position: { x: ACTOR_X_POSITIONS.authServer, y: 0 }, + data: { + label: ACTORS.authServer.label, + color: ACTORS.authServer.color, + totalHeight: totalSegmentHeight, + segments: authServerSegments, + }, + draggable: false, + }, + ]; + + // Create action edges + const edges: Edge[] = actions.map((action) => { + const status = getActionStatus(action.id, currentStep, actions); + const isComplete = status === "complete"; + const isCurrent = status === "current"; + const isPending = status === "pending"; + + const arrowColor = isComplete + ? "#10b981" + : isCurrent + ? "#3b82f6" + : "#d1d5db"; + + const sourceX = + ACTOR_X_POSITIONS[action.from as keyof typeof ACTOR_X_POSITIONS]; + const targetX = + ACTOR_X_POSITIONS[action.to as keyof typeof ACTOR_X_POSITIONS]; + const isLeftToRight = sourceX < targetX; + + return { + id: `edge-${action.id}`, + source: `actor-${action.from}`, + target: `actor-${action.to}`, + sourceHandle: isLeftToRight + ? `${action.id}-right-source` + : `${action.id}-left-source`, + targetHandle: isLeftToRight + ? `${action.id}-left-target` + : `${action.id}-right-target`, + type: "actionEdge", + data: { + label: action.label, + description: action.description, + status, + details: action.details, + }, + animated: isCurrent, + markerEnd: { + type: "arrowclosed" as const, + color: arrowColor, + width: 12, + height: 12, + }, + style: { + stroke: arrowColor, + strokeWidth: isCurrent ? 3 : isComplete ? 2 : 1.5, + strokeDasharray: isCurrent ? "5,5" : undefined, + opacity: isPending ? 0.4 : 1, + }, + }; + }); + + return { nodes, edges }; +} diff --git a/client/src/components/oauth/shared/index.ts b/client/src/components/oauth/shared/index.ts new file mode 100644 index 000000000..e34e5ab46 --- /dev/null +++ b/client/src/components/oauth/shared/index.ts @@ -0,0 +1,8 @@ +export * from "./types"; +export * from "./constants"; +export * from "./utils"; +export * from "./diagramBuilder"; +export { ActorNode } from "./ActorNode"; +export { CustomActionEdge } from "./CustomActionEdge"; +export { DiagramLayout } from "./DiagramLayout"; +export { OAuthSequenceDiagramContent } from "./OAuthSequenceDiagramContent"; diff --git a/client/src/components/oauth/shared/types.ts b/client/src/components/oauth/shared/types.ts new file mode 100644 index 000000000..1ae363ac0 --- /dev/null +++ b/client/src/components/oauth/shared/types.ts @@ -0,0 +1,34 @@ +import type { ReactNode } from "react"; + +export type NodeStatus = "complete" | "current" | "pending"; + +// Actor/Swimlane node types +export interface ActorNodeData extends Record { + label: string; + color: string; + totalHeight: number; // Total height for alignment + segments: Array<{ + id: string; + type: "box" | "line"; + height: number; + handleId?: string; + }>; +} + +// Edge data for action labels +export interface ActionEdgeData extends Record { + label: string; + description: string; + status: NodeStatus; + details?: Array<{ label: string; value: ReactNode }>; +} + +// Action definition for sequence diagram +export interface Action { + id: string; + label: string; + description: string; + from: string; + to: string; + details?: Array<{ label: string; value: ReactNode }>; +} diff --git a/client/src/components/oauth/shared/utils.ts b/client/src/components/oauth/shared/utils.ts new file mode 100644 index 000000000..e9024e8a1 --- /dev/null +++ b/client/src/components/oauth/shared/utils.ts @@ -0,0 +1,22 @@ +import type { OAuthFlowStep } from "@/lib/oauth/state-machines/types"; +import type { NodeStatus } from "./types"; + +// Helper to determine status based on current step and actual action order +export const getActionStatus = ( + actionStep: OAuthFlowStep | string, + currentStep: OAuthFlowStep, + actionsInFlow: Array<{ id: string }>, +): NodeStatus => { + // Find indices in the actual flow (not a hardcoded order) + const actionIndex = actionsInFlow.findIndex((a) => a.id === actionStep); + const currentIndex = actionsInFlow.findIndex((a) => a.id === currentStep); + + // If step not found in flow, it's pending + if (actionIndex === -1) return "pending"; + + // Show completed steps (everything up to and including current) + if (actionIndex <= currentIndex) return "complete"; + // Show the NEXT step as current (what will happen when you click Next Step) + if (actionIndex === currentIndex + 1) return "current"; + return "pending"; +}; diff --git a/client/src/lib/debug-oauth-state-machine.ts b/client/src/lib/debug-oauth-state-machine.ts deleted file mode 100644 index 2c026a27b..000000000 --- a/client/src/lib/debug-oauth-state-machine.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * OAuth State Machine Facade - * - * This file provides backward compatibility by re-exporting from the - * protocol-specific state machine implementations. - * - * For new code, prefer importing from: - * - `./oauth/state-machines/factory` for version-aware creation - * - `./oauth/state-machines/types` for shared types - * - `./oauth/state-machines/debug-oauth-2025-06-18` for 2025-06-18 specific - * - `./oauth/state-machines/debug-oauth-2025-11-25` for 2025-11-25 specific - */ - -// Re-export types for backward compatibility -export type { - OAuthFlowStep, - OAuthFlowState, - OAuthStateMachine, - OAuthProtocolVersion, - RegistrationStrategy2025_06_18, - RegistrationStrategy2025_11_25, -} from "./oauth/state-machines/types"; - -export { EMPTY_OAUTH_FLOW_STATE } from "./oauth/state-machines/types"; - -// Legacy type aliases -export type { OauthFlowStateJune2025 } from "./oauth/state-machines/debug-oauth-2025-06-18"; -export type { DebugOAuthStateMachine } from "./oauth/state-machines/debug-oauth-2025-06-18"; - -// Legacy state export -export { EMPTY_OAUTH_FLOW_STATE_V2 } from "./oauth/state-machines/debug-oauth-2025-06-18"; - -// Re-export factory and utilities -export { - createOAuthStateMachine, - getDefaultRegistrationStrategy, - getSupportedRegistrationStrategies, - PROTOCOL_VERSION_INFO, - type OAuthStateMachineFactoryConfig, -} from "./oauth/state-machines/factory"; - -// Re-export individual state machine creators for advanced use cases -export { createDebugOAuthStateMachine as createDebugOAuthStateMachine_2025_06_18 } from "./oauth/state-machines/debug-oauth-2025-06-18"; -export { createDebugOAuthStateMachine as createDebugOAuthStateMachine_2025_11_25 } from "./oauth/state-machines/debug-oauth-2025-11-25"; - -// Backward compatible default export (2025-11-25) -// For legacy code that imports the default state machine -import { createDebugOAuthStateMachine as create2025_11_25 } from "./oauth/state-machines/debug-oauth-2025-11-25"; -export const createDebugOAuthStateMachine = create2025_11_25; - -// Configuration type for backward compatibility -export type { DebugOAuthStateMachineConfig } from "./oauth/state-machines/debug-oauth-2025-11-25"; - -/** - * @deprecated Use createOAuthStateMachine from factory instead for protocol version selection - * - * @example - * ```typescript - * // Old way (still works, defaults to 2025-11-25) - * const machine = createDebugOAuthStateMachine(config); - * - * // New way (recommended - explicit protocol version) - * const machine = createOAuthStateMachine({ - * protocolVersion: "2025-11-25", - * ...config - * }); - * ``` - */ -export const createLegacyStateMachine = create2025_11_25; diff --git a/client/src/lib/oauth/state-machines/debug-oauth-2025-03-26.ts b/client/src/lib/oauth/state-machines/debug-oauth-2025-03-26.ts index 4d6b57f7c..879fea04e 100644 --- a/client/src/lib/oauth/state-machines/debug-oauth-2025-03-26.ts +++ b/client/src/lib/oauth/state-machines/debug-oauth-2025-03-26.ts @@ -17,6 +17,14 @@ import type { OAuthStateMachine, RegistrationStrategy2025_03_26, } from "./types"; +import type { DiagramAction } from "./shared/types"; +import { + proxyFetch, + addInfoLog, + generateRandomString, + generateCodeChallenge, + loadPreregisteredCredentials, +} from "./shared/helpers"; // Re-export types for backward compatibility export type { OAuthFlowStep, OAuthFlowState }; @@ -29,11 +37,6 @@ export type OauthFlowStateJune2025 = OAuthFlowState; export const EMPTY_OAUTH_FLOW_STATE_V2: OauthFlowStateJune2025 = EMPTY_OAUTH_FLOW_STATE; -// State machine interface (legacy compatibility) -export interface DebugOAuthStateMachine extends OAuthStateMachine { - // All methods inherited from OAuthStateMachine -} - // Configuration for creating the state machine (2025-03-26 specific) export interface DebugOAuthStateMachineConfig { state: OauthFlowStateJune2025; @@ -48,6 +51,257 @@ export interface DebugOAuthStateMachineConfig { registrationStrategy?: RegistrationStrategy2025_03_26; // dcr | preregistered only } +/** + * Build the sequence of actions for the 2025-03-26 OAuth flow + * This function creates the visual representation of the OAuth flow steps + * that will be displayed in the sequence diagram. + */ +export function buildActions_2025_03_26( + flowState: OAuthFlowState, + registrationStrategy: "dcr" | "preregistered", +): DiagramAction[] { + return [ + // 2025-03-26: NO Protected Resource Metadata (RFC9728) support + // Flow starts directly with Authorization Server Metadata discovery + { + id: "request_authorization_server_metadata", + label: "GET /.well-known/oauth-authorization-server from MCP base URL", + description: + "Direct discovery from MCP server base URL with fallback to /authorize, /token, /register", + from: "client", + to: "authServer", + details: flowState.authorizationServerUrl + ? [ + { label: "URL", value: flowState.authorizationServerUrl }, + { label: "Protocol", value: "2025-03-26" }, + ] + : undefined, + }, + { + id: "received_authorization_server_metadata", + label: "Authorization server metadata response", + description: "Authorization Server returns metadata", + from: "authServer", + to: "client", + details: flowState.authorizationServerMetadata + ? [ + { + label: "Token", + value: new URL( + flowState.authorizationServerMetadata.token_endpoint, + ).pathname, + }, + { + label: "Auth", + value: new URL( + flowState.authorizationServerMetadata.authorization_endpoint, + ).pathname, + }, + ] + : undefined, + }, + // Client registration steps (no CIMD support in 2025-03-26) + ...(registrationStrategy === "dcr" + ? [ + { + id: "request_client_registration", + label: "POST /register (2025-03-26)", + description: + "Client registers dynamically with Authorization Server", + from: "client", + to: "authServer", + details: [ + { + label: "Note", + value: "Dynamic client registration (DCR)", + }, + ], + }, + { + id: "received_client_credentials", + label: "Client Credentials", + description: + "Authorization Server returns client ID and credentials", + from: "authServer", + to: "client", + details: flowState.clientId + ? [ + { + label: "client_id", + value: flowState.clientId.substring(0, 20) + "...", + }, + ] + : undefined, + }, + ] + : [ + { + id: "received_client_credentials", + label: "Use Pre-registered Client (2025-03-26)", + description: "Client uses pre-configured credentials (skipped DCR)", + from: "client", + to: "client", + details: flowState.clientId + ? [ + { + label: "client_id", + value: flowState.clientId.substring(0, 20) + "...", + }, + { + label: "Note", + value: "Pre-registered (no DCR needed)", + }, + ] + : [ + { + label: "Note", + value: "Pre-registered client credentials", + }, + ], + }, + ]), + { + id: "generate_pkce_parameters", + label: "Generate PKCE parameters", + description: + "Client generates code verifier and challenge (recommended), includes resource parameter", + from: "client", + to: "client", + details: flowState.codeChallenge + ? [ + { + label: "code_challenge", + value: flowState.codeChallenge.substring(0, 15) + "...", + }, + { + label: "method", + value: flowState.codeChallengeMethod || "S256", + }, + { label: "resource", value: flowState.serverUrl || "—" }, + { label: "Protocol", value: "2025-03-26" }, + ] + : undefined, + }, + { + id: "authorization_request", + label: "Open browser with authorization URL", + description: + "Client opens browser with authorization URL + code_challenge + resource", + from: "client", + to: "browser", + details: flowState.authorizationUrl + ? [ + { + label: "code_challenge", + value: + flowState.codeChallenge?.substring(0, 12) + "..." || "S256", + }, + { label: "resource", value: flowState.serverUrl || "" }, + ] + : undefined, + }, + { + id: "browser_to_auth_server", + label: "Authorization request with resource parameter", + description: "Browser navigates to authorization endpoint", + from: "browser", + to: "authServer", + details: flowState.authorizationUrl + ? [{ label: "Note", value: "User authorizes in browser" }] + : undefined, + }, + { + id: "auth_redirect_to_browser", + label: "Redirect to callback with authorization code", + description: + "Authorization Server redirects browser back to callback URL", + from: "authServer", + to: "browser", + details: flowState.authorizationCode + ? [ + { + label: "code", + value: flowState.authorizationCode.substring(0, 20) + "...", + }, + ] + : undefined, + }, + { + id: "received_authorization_code", + label: "Authorization code callback", + description: "Browser redirects back to client with authorization code", + from: "browser", + to: "client", + details: flowState.authorizationCode + ? [ + { + label: "code", + value: flowState.authorizationCode.substring(0, 20) + "...", + }, + ] + : undefined, + }, + { + id: "token_request", + label: "Token request + code_verifier + resource", + description: "Client exchanges authorization code for access token", + from: "client", + to: "authServer", + details: flowState.codeVerifier + ? [ + { label: "grant_type", value: "authorization_code" }, + { label: "resource", value: flowState.serverUrl || "" }, + ] + : undefined, + }, + { + id: "received_access_token", + label: "Access token (+ refresh token)", + description: "Authorization Server returns access token", + from: "authServer", + to: "client", + details: flowState.accessToken + ? [ + { label: "token_type", value: flowState.tokenType || "Bearer" }, + { + label: "expires_in", + value: flowState.expiresIn?.toString() || "3600", + }, + ] + : undefined, + }, + { + id: "authenticated_mcp_request", + label: "MCP request with access token", + description: "Client makes authenticated request to MCP server", + from: "client", + to: "mcpServer", + details: flowState.accessToken + ? [ + { label: "POST", value: "tools/list" }, + { + label: "Authorization", + value: "Bearer " + flowState.accessToken.substring(0, 15) + "...", + }, + ] + : undefined, + }, + { + id: "complete", + label: "MCP response", + description: "MCP Server returns successful response", + from: "mcpServer", + to: "client", + details: flowState.accessToken + ? [ + { label: "Status", value: "200 OK" }, + { label: "Content", value: "tools, resources, prompts" }, + ] + : undefined, + }, + ]; +} + // Helper: Build authorization base URL from MCP server URL (2025-03-26 specific) // Discards path component and uses origin function buildAuthorizationBaseUrl(serverUrl: string): string { @@ -86,104 +340,10 @@ function buildAuthServerMetadataUrls(serverUrl: string): string[] { return urls; } -// Helper: Load pre-registered OAuth credentials from localStorage -function loadPreregisteredCredentials(serverName: string): { - clientId?: string; - clientSecret?: string; -} { - try { - // Try to load from mcp-client-{serverName} (where ServerModal stores them) - const storedClientInfo = localStorage.getItem(`mcp-client-${serverName}`); - if (storedClientInfo) { - const parsed = JSON.parse(storedClientInfo); - return { - clientId: parsed.client_id || undefined, - clientSecret: parsed.client_secret || undefined, - }; - } - } catch (e) { - console.error("Failed to load pre-registered credentials:", e); - } - return {}; -} - -/** - * Helper function to make requests via backend debug proxy (bypasses CORS) - */ -async function proxyFetch( - url: string, - options: RequestInit = {}, -): Promise<{ - status: number; - statusText: string; - headers: Record; - body: any; - ok: boolean; -}> { - const defaultHeaders: Record = { - Accept: "application/json, text/event-stream", - }; - - const mergedHeaders = { - ...defaultHeaders, - ...((options.headers as Record) || {}), - }; - - let bodyToSend: any = undefined; - if (options.body) { - const contentType = - mergedHeaders[ - Object.keys(mergedHeaders).find( - (k) => k.toLowerCase() === "content-type", - ) || "" - ]; - - if (contentType?.includes("application/x-www-form-urlencoded")) { - const params = new URLSearchParams(options.body as string); - bodyToSend = Object.fromEntries(params.entries()); - } else if (typeof options.body === "string") { - try { - bodyToSend = JSON.parse(options.body); - } catch { - bodyToSend = options.body; - } - } else { - bodyToSend = options.body; - } - } - - const proxyPayload = { - url, - method: options.method || "GET", - body: bodyToSend, - headers: mergedHeaders, - }; - - const response = await fetch("/api/mcp/oauth/debug/proxy", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(proxyPayload), - }); - - if (!response.ok) { - throw new Error( - `Backend debug proxy error: ${response.status} ${response.statusText}`, - ); - } - - const data = await response.json(); - return { - ...data, - ok: data.status >= 200 && data.status < 300, - }; -} - // Factory function to create the 2025-03-26 state machine export const createDebugOAuthStateMachine = ( config: DebugOAuthStateMachineConfig, -): DebugOAuthStateMachine => { +): OAuthStateMachine => { const { state: initialState, getState, @@ -209,7 +369,7 @@ export const createDebugOAuthStateMachine = ( const getCurrentState = () => (getState ? getState() : initialState); - const machine: DebugOAuthStateMachine = { + const machine: OAuthStateMachine = { state: initialState, updateState, @@ -1574,42 +1734,3 @@ export const createDebugOAuthStateMachine = ( return machine; }; - -// Helper function to add an info log to the state -function addInfoLog( - state: OauthFlowStateJune2025, - id: string, - label: string, - data: any, -): Array<{ id: string; label: string; data: any; timestamp: number }> { - return [ - ...(state.infoLogs || []), - { - id, - label, - data, - timestamp: Date.now(), - }, - ]; -} - -// Helper function to generate random string for PKCE -function generateRandomString(length: number): string { - const charset = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; - const randomValues = new Uint8Array(length); - crypto.getRandomValues(randomValues); - return Array.from( - randomValues, - (byte) => charset[byte % charset.length], - ).join(""); -} - -// Helper function to generate code challenge from verifier -async function generateCodeChallenge(verifier: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(verifier); - const hash = await crypto.subtle.digest("SHA-256", data); - const base64 = btoa(String.fromCharCode(...new Uint8Array(hash))); - return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); -} diff --git a/client/src/lib/oauth/state-machines/debug-oauth-2025-06-18.ts b/client/src/lib/oauth/state-machines/debug-oauth-2025-06-18.ts index f83a0002b..c1228535c 100644 --- a/client/src/lib/oauth/state-machines/debug-oauth-2025-06-18.ts +++ b/client/src/lib/oauth/state-machines/debug-oauth-2025-06-18.ts @@ -16,6 +16,15 @@ import type { OAuthStateMachine, RegistrationStrategy2025_06_18, } from "./types"; +import type { DiagramAction } from "./shared/types"; +import { + proxyFetch, + addInfoLog, + generateRandomString, + generateCodeChallenge, + loadPreregisteredCredentials, + buildResourceMetadataUrl, +} from "./shared/helpers"; // Re-export types for backward compatibility export type { OAuthFlowStep, OAuthFlowState }; @@ -28,11 +37,6 @@ export type OauthFlowStateJune2025 = OAuthFlowState; export const EMPTY_OAUTH_FLOW_STATE_V2: OauthFlowStateJune2025 = EMPTY_OAUTH_FLOW_STATE; -// State machine interface (legacy compatibility) -export interface DebugOAuthStateMachine extends OAuthStateMachine { - // All methods inherited from OAuthStateMachine -} - // Configuration for creating the state machine (2025-06-18 specific) export interface DebugOAuthStateMachineConfig { state: OauthFlowStateJune2025; @@ -47,25 +51,305 @@ export interface DebugOAuthStateMachineConfig { registrationStrategy?: RegistrationStrategy2025_06_18; // dcr | preregistered only } -// Helper: Build well-known resource metadata URL from server URL -// This follows RFC 9728 OAuth 2.0 Protected Resource Metadata -function buildResourceMetadataUrl(serverUrl: string): string { - const url = new URL(serverUrl); - // Try path-aware discovery first (if server has a path) - if (url.pathname !== "/" && url.pathname !== "") { - const pathname = url.pathname.endsWith("/") - ? url.pathname.slice(0, -1) - : url.pathname; - return new URL( - `/.well-known/oauth-protected-resource${pathname}`, - url.origin, - ).toString(); - } - // Otherwise use root discovery - return new URL( - "/.well-known/oauth-protected-resource", - url.origin, - ).toString(); +/** + * Build the sequence of actions for the 2025-06-18 OAuth flow + * This function creates the visual representation of the OAuth flow steps + * that will be displayed in the sequence diagram. + */ +export function buildActions_2025_06_18( + flowState: OAuthFlowState, + registrationStrategy: "dcr" | "preregistered", +): DiagramAction[] { + return [ + { + id: "request_without_token", + label: "MCP request without token", + description: "Client makes initial request without authorization", + from: "client", + to: "mcpServer", + details: flowState.serverUrl + ? [ + { label: "POST", value: flowState.serverUrl }, + { label: "method", value: "initialize" }, + ] + : undefined, + }, + { + id: "received_401_unauthorized", + label: "HTTP 401 Unauthorized with WWW-Authenticate header", + description: "Server returns 401 with WWW-Authenticate header", + from: "mcpServer", + to: "client", + details: flowState.resourceMetadataUrl + ? [{ label: "Note", value: "Extract resource_metadata URL" }] + : undefined, + }, + { + id: "request_resource_metadata", + label: "Request Protected Resource Metadata", + description: "Client requests metadata from well-known URI", + from: "client", + to: "mcpServer", + details: flowState.resourceMetadataUrl + ? [ + { + label: "GET", + value: new URL(flowState.resourceMetadataUrl).pathname, + }, + ] + : undefined, + }, + { + id: "received_resource_metadata", + label: "Return metadata", + description: "Server returns OAuth protected resource metadata", + from: "mcpServer", + to: "client", + details: flowState.resourceMetadata?.authorization_servers + ? [ + { + label: "Auth Server", + value: flowState.resourceMetadata.authorization_servers[0], + }, + ] + : undefined, + }, + { + id: "request_authorization_server_metadata", + label: "GET Authorization server metadata endpoint", + description: "Try RFC8414 path, then RFC8414 root (no OIDC support)", + from: "client", + to: "authServer", + details: flowState.authorizationServerUrl + ? [ + { label: "URL", value: flowState.authorizationServerUrl }, + { label: "Protocol", value: "2025-06-18" }, + ] + : undefined, + }, + { + id: "received_authorization_server_metadata", + label: "Authorization server metadata response", + description: "Authorization Server returns metadata", + from: "authServer", + to: "client", + details: flowState.authorizationServerMetadata + ? [ + { + label: "Token", + value: new URL( + flowState.authorizationServerMetadata.token_endpoint, + ).pathname, + }, + { + label: "Auth", + value: new URL( + flowState.authorizationServerMetadata.authorization_endpoint, + ).pathname, + }, + ] + : undefined, + }, + // Client registration steps (no CIMD support in 2025-06-18) + ...(registrationStrategy === "dcr" + ? [ + { + id: "request_client_registration", + label: "POST /register (2025-06-18)", + description: + "Client registers dynamically with Authorization Server", + from: "client", + to: "authServer", + details: [ + { + label: "Note", + value: "Dynamic client registration (DCR)", + }, + ], + }, + { + id: "received_client_credentials", + label: "Client Credentials", + description: + "Authorization Server returns client ID and credentials", + from: "authServer", + to: "client", + details: flowState.clientId + ? [ + { + label: "client_id", + value: flowState.clientId.substring(0, 20) + "...", + }, + ] + : undefined, + }, + ] + : [ + { + id: "received_client_credentials", + label: "Use Pre-registered Client (2025-06-18)", + description: "Client uses pre-configured credentials (skipped DCR)", + from: "client", + to: "client", + details: flowState.clientId + ? [ + { + label: "client_id", + value: flowState.clientId.substring(0, 20) + "...", + }, + { + label: "Note", + value: "Pre-registered (no DCR needed)", + }, + ] + : [ + { + label: "Note", + value: "Pre-registered client credentials", + }, + ], + }, + ]), + { + id: "generate_pkce_parameters", + label: "Generate PKCE parameters", + description: + "Client generates code verifier and challenge (recommended), includes resource parameter", + from: "client", + to: "client", + details: flowState.codeChallenge + ? [ + { + label: "code_challenge", + value: flowState.codeChallenge.substring(0, 15) + "...", + }, + { + label: "method", + value: flowState.codeChallengeMethod || "S256", + }, + { label: "resource", value: flowState.serverUrl || "—" }, + { label: "Protocol", value: "2025-06-18" }, + ] + : undefined, + }, + { + id: "authorization_request", + label: "Open browser with authorization URL", + description: + "Client opens browser with authorization URL + code_challenge + resource", + from: "client", + to: "browser", + details: flowState.authorizationUrl + ? [ + { + label: "code_challenge", + value: + flowState.codeChallenge?.substring(0, 12) + "..." || "S256", + }, + { label: "resource", value: flowState.serverUrl || "" }, + ] + : undefined, + }, + { + id: "browser_to_auth_server", + label: "Authorization request with resource parameter", + description: "Browser navigates to authorization endpoint", + from: "browser", + to: "authServer", + details: flowState.authorizationUrl + ? [{ label: "Note", value: "User authorizes in browser" }] + : undefined, + }, + { + id: "auth_redirect_to_browser", + label: "Redirect to callback with authorization code", + description: + "Authorization Server redirects browser back to callback URL", + from: "authServer", + to: "browser", + details: flowState.authorizationCode + ? [ + { + label: "code", + value: flowState.authorizationCode.substring(0, 20) + "...", + }, + ] + : undefined, + }, + { + id: "received_authorization_code", + label: "Authorization code callback", + description: "Browser redirects back to client with authorization code", + from: "browser", + to: "client", + details: flowState.authorizationCode + ? [ + { + label: "code", + value: flowState.authorizationCode.substring(0, 20) + "...", + }, + ] + : undefined, + }, + { + id: "token_request", + label: "Token request + code_verifier + resource", + description: "Client exchanges authorization code for access token", + from: "client", + to: "authServer", + details: flowState.codeVerifier + ? [ + { label: "grant_type", value: "authorization_code" }, + { label: "resource", value: flowState.serverUrl || "" }, + ] + : undefined, + }, + { + id: "received_access_token", + label: "Access token (+ refresh token)", + description: "Authorization Server returns access token", + from: "authServer", + to: "client", + details: flowState.accessToken + ? [ + { label: "token_type", value: flowState.tokenType || "Bearer" }, + { + label: "expires_in", + value: flowState.expiresIn?.toString() || "3600", + }, + ] + : undefined, + }, + { + id: "authenticated_mcp_request", + label: "MCP request with access token", + description: "Client makes authenticated request to MCP server", + from: "client", + to: "mcpServer", + details: flowState.accessToken + ? [ + { label: "POST", value: "tools/list" }, + { + label: "Authorization", + value: "Bearer " + flowState.accessToken.substring(0, 15) + "...", + }, + ] + : undefined, + }, + { + id: "complete", + label: "MCP response", + description: "MCP Server returns successful response", + from: "mcpServer", + to: "client", + details: flowState.accessToken + ? [ + { label: "Status", value: "200 OK" }, + { label: "Content", value: "tools, resources, prompts" }, + ] + : undefined, + }, + ]; } // Helper: Build authorization server metadata URLs to try (RFC 8414 ONLY) @@ -100,115 +384,10 @@ function buildAuthServerMetadataUrls(authServerUrl: string): string[] { return urls; } -// Helper: Load pre-registered OAuth credentials from localStorage -function loadPreregisteredCredentials(serverName: string): { - clientId?: string; - clientSecret?: string; -} { - try { - // Try to load from mcp-client-{serverName} (where ServerModal stores them) - const storedClientInfo = localStorage.getItem(`mcp-client-${serverName}`); - if (storedClientInfo) { - const parsed = JSON.parse(storedClientInfo); - return { - clientId: parsed.client_id || undefined, - clientSecret: parsed.client_secret || undefined, - }; - } - } catch (e) { - console.error("Failed to load pre-registered credentials:", e); - } - return {}; -} - -/** - * Helper function to make requests via backend debug proxy (bypasses CORS) - * - * Uses the debug-specific proxy endpoint for OAuth flow visualization. - * Automatically adds MCP-required headers so you don't have to remember them: - * - Accept: "application/json, text/event-stream" (required by MCP HTTP transport spec) - * - * You can still override these by passing custom headers in options.headers - */ -async function proxyFetch( - url: string, - options: RequestInit = {}, -): Promise<{ - status: number; - statusText: string; - headers: Record; - body: any; - ok: boolean; -}> { - // Merge headers with MCP-required defaults - // Per MCP spec: HTTP clients MUST include both application/json and text/event-stream - const defaultHeaders: Record = { - Accept: "application/json, text/event-stream", - }; - - const mergedHeaders = { - ...defaultHeaders, - ...((options.headers as Record) || {}), - }; - - // Determine if body is JSON or form-urlencoded - let bodyToSend: any = undefined; - if (options.body) { - const contentType = - mergedHeaders[ - Object.keys(mergedHeaders).find( - (k) => k.toLowerCase() === "content-type", - ) || "" - ]; - - if (contentType?.includes("application/x-www-form-urlencoded")) { - // For form-urlencoded, convert to object - const params = new URLSearchParams(options.body as string); - bodyToSend = Object.fromEntries(params.entries()); - } else if (typeof options.body === "string") { - // Try to parse as JSON - try { - bodyToSend = JSON.parse(options.body); - } catch { - bodyToSend = options.body; - } - } else { - bodyToSend = options.body; - } - } - - const proxyPayload = { - url, - method: options.method || "GET", - body: bodyToSend, - headers: mergedHeaders, - }; - - const response = await fetch("/api/mcp/oauth/debug/proxy", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(proxyPayload), - }); - - if (!response.ok) { - throw new Error( - `Backend debug proxy error: ${response.status} ${response.statusText}`, - ); - } - - const data = await response.json(); - return { - ...data, - ok: data.status >= 200 && data.status < 300, - }; -} - // Factory function to create the 2025-06-18 state machine export const createDebugOAuthStateMachine = ( config: DebugOAuthStateMachineConfig, -): DebugOAuthStateMachine => { +): OAuthStateMachine => { const { state: initialState, getState, @@ -238,7 +417,7 @@ export const createDebugOAuthStateMachine = ( const getCurrentState = () => (getState ? getState() : initialState); // Create machine object that can reference itself - const machine: DebugOAuthStateMachine = { + const machine: OAuthStateMachine = { state: initialState, updateState, @@ -1779,42 +1958,3 @@ export const createDebugOAuthStateMachine = ( return machine; }; - -// Helper function to add an info log to the state -function addInfoLog( - state: OauthFlowStateJune2025, - id: string, - label: string, - data: any, -): Array<{ id: string; label: string; data: any; timestamp: number }> { - return [ - ...(state.infoLogs || []), - { - id, - label, - data, - timestamp: Date.now(), - }, - ]; -} - -// Helper function to generate random string for PKCE -function generateRandomString(length: number): string { - const charset = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; - const randomValues = new Uint8Array(length); - crypto.getRandomValues(randomValues); - return Array.from( - randomValues, - (byte) => charset[byte % charset.length], - ).join(""); -} - -// Helper function to generate code challenge from verifier -async function generateCodeChallenge(verifier: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(verifier); - const hash = await crypto.subtle.digest("SHA-256", data); - const base64 = btoa(String.fromCharCode(...new Uint8Array(hash))); - return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); -} diff --git a/client/src/lib/oauth/state-machines/debug-oauth-2025-11-25.ts b/client/src/lib/oauth/state-machines/debug-oauth-2025-11-25.ts index 4f1741301..d61ac73b2 100644 --- a/client/src/lib/oauth/state-machines/debug-oauth-2025-11-25.ts +++ b/client/src/lib/oauth/state-machines/debug-oauth-2025-11-25.ts @@ -17,6 +17,15 @@ import type { OAuthStateMachine, RegistrationStrategy2025_11_25, } from "./types"; +import type { DiagramAction } from "./shared/types"; +import { + proxyFetch, + addInfoLog, + generateRandomString, + generateCodeChallenge, + loadPreregisteredCredentials, + buildResourceMetadataUrl, +} from "./shared/helpers"; // Re-export types for backward compatibility export type { OAuthFlowStep, OAuthFlowState }; @@ -29,11 +38,6 @@ export type OauthFlowStateJune2025 = OAuthFlowState; export const EMPTY_OAUTH_FLOW_STATE_V2: OauthFlowStateJune2025 = EMPTY_OAUTH_FLOW_STATE; -// State machine interface (legacy compatibility) -export interface DebugOAuthStateMachine extends OAuthStateMachine { - // All methods inherited from OAuthStateMachine -} - // Configuration for creating the state machine (2025-11-25 specific) export interface DebugOAuthStateMachineConfig { state: OauthFlowStateJune2025; @@ -48,25 +52,390 @@ export interface DebugOAuthStateMachineConfig { registrationStrategy?: RegistrationStrategy2025_11_25; // cimd | dcr | preregistered } -// Helper: Build well-known resource metadata URL from server URL -// This follows RFC 9728 OAuth 2.0 Protected Resource Metadata -function buildResourceMetadataUrl(serverUrl: string): string { - const url = new URL(serverUrl); - // Try path-aware discovery first (if server has a path) - if (url.pathname !== "/" && url.pathname !== "") { - const pathname = url.pathname.endsWith("/") - ? url.pathname.slice(0, -1) - : url.pathname; - return new URL( - `/.well-known/oauth-protected-resource${pathname}`, - url.origin, - ).toString(); - } - // Otherwise use root discovery - return new URL( - "/.well-known/oauth-protected-resource", - url.origin, - ).toString(); +/** + * Build the sequence of actions for the 2025-11-25 OAuth flow + * This function creates the visual representation of the OAuth flow steps + * that will be displayed in the sequence diagram. + */ +export function buildActions_2025_11_25( + flowState: OAuthFlowState, + registrationStrategy: "cimd" | "dcr" | "preregistered", +): DiagramAction[] { + return [ + { + id: "request_without_token", + label: "MCP request without token", + description: "Client makes initial request without authorization", + from: "client", + to: "mcpServer", + details: flowState.serverUrl + ? [ + { label: "POST", value: flowState.serverUrl }, + { label: "method", value: "initialize" }, + ] + : undefined, + }, + { + id: "received_401_unauthorized", + label: "HTTP 401 Unauthorized with WWW-Authenticate header", + description: "Server returns 401 with WWW-Authenticate header", + from: "mcpServer", + to: "client", + details: flowState.resourceMetadataUrl + ? [{ label: "Note", value: "Extract resource_metadata URL" }] + : undefined, + }, + { + id: "request_resource_metadata", + label: "Request Protected Resource Metadata", + description: "Client requests metadata from well-known URI", + from: "client", + to: "mcpServer", + details: flowState.resourceMetadataUrl + ? [ + { + label: "GET", + value: new URL(flowState.resourceMetadataUrl).pathname, + }, + ] + : undefined, + }, + { + id: "received_resource_metadata", + label: "Return metadata", + description: "Server returns OAuth protected resource metadata", + from: "mcpServer", + to: "client", + details: flowState.resourceMetadata?.authorization_servers + ? [ + { + label: "Auth Server", + value: flowState.resourceMetadata.authorization_servers[0], + }, + ] + : undefined, + }, + { + id: "request_authorization_server_metadata", + label: "GET Authorization server metadata endpoint", + description: + "Try OAuth path insertion, OIDC path insertion, OIDC path appending", + from: "client", + to: "authServer", + details: flowState.authorizationServerUrl + ? [ + { label: "URL", value: flowState.authorizationServerUrl }, + { label: "Protocol", value: "2025-11-25" }, + ] + : undefined, + }, + { + id: "received_authorization_server_metadata", + label: "Authorization server metadata response", + description: "Authorization Server returns metadata", + from: "authServer", + to: "client", + details: flowState.authorizationServerMetadata + ? [ + { + label: "Token", + value: new URL( + flowState.authorizationServerMetadata.token_endpoint, + ).pathname, + }, + { + label: "Auth", + value: new URL( + flowState.authorizationServerMetadata.authorization_endpoint, + ).pathname, + }, + ] + : undefined, + }, + // CIMD steps + ...(registrationStrategy === "cimd" + ? [ + { + id: "cimd_prepare", + label: "Client uses HTTPS URL as client_id", + description: + "Client prepares to use URL-based client identification", + from: "client", + to: "client", + details: flowState.clientId + ? [ + { + label: "client_id (URL)", + value: flowState.clientId.includes("http") + ? flowState.clientId + : "https://www.mcpjam.com/.well-known/oauth/client-metadata.json", + }, + { + label: "Method", + value: "Client ID Metadata Document (CIMD)", + }, + ] + : [ + { + label: "Note", + value: "HTTPS URL points to metadata document", + }, + ], + }, + { + id: "cimd_fetch_request", + label: "Fetch metadata from client_id URL", + description: + "Authorization Server fetches client metadata from the URL", + from: "authServer", + to: "client", + details: [ + { + label: "Action", + value: "GET client_id URL", + }, + { + label: "Note", + value: "Server initiates metadata fetch during authorization", + }, + ], + }, + { + id: "cimd_metadata_response", + label: "JSON metadata document", + description: + "Client hosting returns metadata with redirect_uris and client info", + from: "client", + to: "authServer", + details: [ + { + label: "Content-Type", + value: "application/json", + }, + { + label: "Contains", + value: "client_id, client_name, redirect_uris, etc.", + }, + ], + }, + { + id: "received_client_credentials", + label: "Validate metadata and redirect_uris", + description: "Authorization Server validates fetched metadata", + from: "authServer", + to: "authServer", + details: [ + { + label: "Validates", + value: "client_id matches URL, redirect_uris are valid", + }, + { + label: "Security", + value: "SSRF protection, domain trust policies", + }, + ], + }, + ] + : registrationStrategy === "dcr" + ? [ + { + id: "request_client_registration", + label: "POST /register (2025-11-25)", + description: + "Client registers dynamically with Authorization Server", + from: "client", + to: "authServer", + details: [ + { + label: "Note", + value: "Dynamic client registration (DCR)", + }, + ], + }, + { + id: "received_client_credentials", + label: "Client Credentials", + description: + "Authorization Server returns client ID and credentials", + from: "authServer", + to: "client", + details: flowState.clientId + ? [ + { + label: "client_id", + value: flowState.clientId.substring(0, 20) + "...", + }, + ] + : undefined, + }, + ] + : [ + { + id: "received_client_credentials", + label: "Use Pre-registered Client (2025-11-25)", + description: + "Client uses pre-configured credentials (skipped DCR)", + from: "client", + to: "client", + details: flowState.clientId + ? [ + { + label: "client_id", + value: flowState.clientId.substring(0, 20) + "...", + }, + { + label: "Note", + value: "Pre-registered (no DCR needed)", + }, + ] + : [ + { + label: "Note", + value: "Pre-registered client credentials", + }, + ], + }, + ]), + { + id: "generate_pkce_parameters", + label: "Generate PKCE (REQUIRED)\nInclude resource parameter", + description: + "Client generates code verifier and challenge (REQUIRED), includes resource parameter", + from: "client", + to: "client", + details: flowState.codeChallenge + ? [ + { + label: "code_challenge", + value: flowState.codeChallenge.substring(0, 15) + "...", + }, + { + label: "method", + value: flowState.codeChallengeMethod || "S256", + }, + { label: "resource", value: flowState.serverUrl || "—" }, + { label: "Protocol", value: "2025-11-25" }, + ] + : undefined, + }, + { + id: "authorization_request", + label: "Open browser with authorization URL", + description: + "Client opens browser with authorization URL + code_challenge + resource", + from: "client", + to: "browser", + details: flowState.authorizationUrl + ? [ + { + label: "code_challenge", + value: + flowState.codeChallenge?.substring(0, 12) + "..." || "S256", + }, + { label: "resource", value: flowState.serverUrl || "" }, + ] + : undefined, + }, + { + id: "browser_to_auth_server", + label: "Authorization request with resource parameter", + description: "Browser navigates to authorization endpoint", + from: "browser", + to: "authServer", + details: flowState.authorizationUrl + ? [{ label: "Note", value: "User authorizes in browser" }] + : undefined, + }, + { + id: "auth_redirect_to_browser", + label: "Redirect to callback with authorization code", + description: + "Authorization Server redirects browser back to callback URL", + from: "authServer", + to: "browser", + details: flowState.authorizationCode + ? [ + { + label: "code", + value: flowState.authorizationCode.substring(0, 20) + "...", + }, + ] + : undefined, + }, + { + id: "received_authorization_code", + label: "Authorization code callback", + description: "Browser redirects back to client with authorization code", + from: "browser", + to: "client", + details: flowState.authorizationCode + ? [ + { + label: "code", + value: flowState.authorizationCode.substring(0, 20) + "...", + }, + ] + : undefined, + }, + { + id: "token_request", + label: "Token request + code_verifier + resource", + description: "Client exchanges authorization code for access token", + from: "client", + to: "authServer", + details: flowState.codeVerifier + ? [ + { label: "grant_type", value: "authorization_code" }, + { label: "resource", value: flowState.serverUrl || "" }, + ] + : undefined, + }, + { + id: "received_access_token", + label: "Access token (+ refresh token)", + description: "Authorization Server returns access token", + from: "authServer", + to: "client", + details: flowState.accessToken + ? [ + { label: "token_type", value: flowState.tokenType || "Bearer" }, + { + label: "expires_in", + value: flowState.expiresIn?.toString() || "3600", + }, + ] + : undefined, + }, + { + id: "authenticated_mcp_request", + label: "MCP request with access token", + description: "Client makes authenticated request to MCP server", + from: "client", + to: "mcpServer", + details: flowState.accessToken + ? [ + { label: "POST", value: "tools/list" }, + { + label: "Authorization", + value: "Bearer " + flowState.accessToken.substring(0, 15) + "...", + }, + ] + : undefined, + }, + { + id: "complete", + label: "MCP response", + description: "MCP Server returns successful response", + from: "mcpServer", + to: "client", + details: flowState.accessToken + ? [ + { label: "Status", value: "200 OK" }, + { label: "Content", value: "tools, resources, prompts" }, + ] + : undefined, + }, + ]; } // Helper: Build authorization server metadata URLs to try (RFC 8414 + OIDC Discovery) @@ -114,115 +483,10 @@ function buildAuthServerMetadataUrls(authServerUrl: string): string[] { return urls; } -// Helper: Load pre-registered OAuth credentials from localStorage -function loadPreregisteredCredentials(serverName: string): { - clientId?: string; - clientSecret?: string; -} { - try { - // Try to load from mcp-client-{serverName} (where ServerModal stores them) - const storedClientInfo = localStorage.getItem(`mcp-client-${serverName}`); - if (storedClientInfo) { - const parsed = JSON.parse(storedClientInfo); - return { - clientId: parsed.client_id || undefined, - clientSecret: parsed.client_secret || undefined, - }; - } - } catch (e) { - console.error("Failed to load pre-registered credentials:", e); - } - return {}; -} - -/** - * Helper function to make requests via backend debug proxy (bypasses CORS) - * - * Uses the debug-specific proxy endpoint for OAuth flow visualization. - * Automatically adds MCP-required headers so you don't have to remember them: - * - Accept: "application/json, text/event-stream" (required by MCP HTTP transport spec) - * - * You can still override these by passing custom headers in options.headers - */ -async function proxyFetch( - url: string, - options: RequestInit = {}, -): Promise<{ - status: number; - statusText: string; - headers: Record; - body: any; - ok: boolean; -}> { - // Merge headers with MCP-required defaults - // Per MCP spec: HTTP clients MUST include both application/json and text/event-stream - const defaultHeaders: Record = { - Accept: "application/json, text/event-stream", - }; - - const mergedHeaders = { - ...defaultHeaders, - ...((options.headers as Record) || {}), - }; - - // Determine if body is JSON or form-urlencoded - let bodyToSend: any = undefined; - if (options.body) { - const contentType = - mergedHeaders[ - Object.keys(mergedHeaders).find( - (k) => k.toLowerCase() === "content-type", - ) || "" - ]; - - if (contentType?.includes("application/x-www-form-urlencoded")) { - // For form-urlencoded, convert to object - const params = new URLSearchParams(options.body as string); - bodyToSend = Object.fromEntries(params.entries()); - } else if (typeof options.body === "string") { - // Try to parse as JSON - try { - bodyToSend = JSON.parse(options.body); - } catch { - bodyToSend = options.body; - } - } else { - bodyToSend = options.body; - } - } - - const proxyPayload = { - url, - method: options.method || "GET", - body: bodyToSend, - headers: mergedHeaders, - }; - - const response = await fetch("/api/mcp/oauth/debug/proxy", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(proxyPayload), - }); - - if (!response.ok) { - throw new Error( - `Backend debug proxy error: ${response.status} ${response.statusText}`, - ); - } - - const data = await response.json(); - return { - ...data, - ok: data.status >= 200 && data.status < 300, - }; -} - // Factory function to create the 2025-11-25 state machine export const createDebugOAuthStateMachine = ( config: DebugOAuthStateMachineConfig, -): DebugOAuthStateMachine => { +): OAuthStateMachine => { const { state: initialState, getState, @@ -252,7 +516,7 @@ export const createDebugOAuthStateMachine = ( const getCurrentState = () => (getState ? getState() : initialState); // Create machine object that can reference itself - const machine: DebugOAuthStateMachine = { + const machine: OAuthStateMachine = { state: initialState, updateState, @@ -1946,42 +2210,3 @@ export const createDebugOAuthStateMachine = ( return machine; }; - -// Helper function to add an info log to the state -function addInfoLog( - state: OauthFlowStateJune2025, - id: string, - label: string, - data: any, -): Array<{ id: string; label: string; data: any; timestamp: number }> { - return [ - ...(state.infoLogs || []), - { - id, - label, - data, - timestamp: Date.now(), - }, - ]; -} - -// Helper function to generate random string for PKCE -function generateRandomString(length: number): string { - const charset = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; - const randomValues = new Uint8Array(length); - crypto.getRandomValues(randomValues); - return Array.from( - randomValues, - (byte) => charset[byte % charset.length], - ).join(""); -} - -// Helper function to generate code challenge from verifier -async function generateCodeChallenge(verifier: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(verifier); - const hash = await crypto.subtle.digest("SHA-256", data); - const base64 = btoa(String.fromCharCode(...new Uint8Array(hash))); - return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); -} diff --git a/client/src/lib/oauth/state-machines/shared/helpers.ts b/client/src/lib/oauth/state-machines/shared/helpers.ts new file mode 100644 index 000000000..1c01e6e08 --- /dev/null +++ b/client/src/lib/oauth/state-machines/shared/helpers.ts @@ -0,0 +1,165 @@ +/** + * Shared helper functions for OAuth state machines + */ + +import type { OAuthFlowState } from "../types"; + +/** + * Helper function to make requests via backend debug proxy (bypasses CORS) + */ +export async function proxyFetch( + url: string, + options: RequestInit = {}, +): Promise<{ + status: number; + statusText: string; + headers: Record; + body: any; + ok: boolean; +}> { + const defaultHeaders: Record = { + Accept: "application/json, text/event-stream", + }; + + const mergedHeaders = { + ...defaultHeaders, + ...((options.headers as Record) || {}), + }; + + let bodyToSend: any = undefined; + if (options.body) { + const contentType = + mergedHeaders[ + Object.keys(mergedHeaders).find( + (k) => k.toLowerCase() === "content-type", + ) || "" + ]; + + if (contentType?.includes("application/x-www-form-urlencoded")) { + const params = new URLSearchParams(options.body as string); + bodyToSend = Object.fromEntries(params.entries()); + } else if (typeof options.body === "string") { + try { + bodyToSend = JSON.parse(options.body); + } catch { + bodyToSend = options.body; + } + } else { + bodyToSend = options.body; + } + } + + const proxyPayload = { + url, + method: options.method || "GET", + body: bodyToSend, + headers: mergedHeaders, + }; + + const response = await fetch("/api/mcp/oauth/debug/proxy", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(proxyPayload), + }); + + if (!response.ok) { + throw new Error( + `Backend debug proxy error: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + return { + ...data, + ok: data.status >= 200 && data.status < 300, + }; +} + +/** + * Helper function to add an info log to the state + */ +export function addInfoLog( + state: OAuthFlowState, + id: string, + label: string, + data: any, +): Array<{ id: string; label: string; data: any; timestamp: number }> { + return [ + ...(state.infoLogs || []), + { + id, + label, + data, + timestamp: Date.now(), + }, + ]; +} + +/** + * Helper function to generate random string for PKCE + */ +export function generateRandomString(length: number): string { + const charset = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + const randomValues = new Uint8Array(length); + crypto.getRandomValues(randomValues); + return Array.from( + randomValues, + (byte) => charset[byte % charset.length], + ).join(""); +} + +/** + * Helper function to generate code challenge from verifier + */ +export async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest("SHA-256", data); + const base64 = btoa(String.fromCharCode(...new Uint8Array(hash))); + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +/** + * Helper: Load pre-registered OAuth credentials from localStorage + */ +export function loadPreregisteredCredentials(serverName: string): { + clientId?: string; + clientSecret?: string; +} { + try { + const storedClientInfo = localStorage.getItem(`mcp-client-${serverName}`); + if (storedClientInfo) { + const parsed = JSON.parse(storedClientInfo); + return { + clientId: parsed.client_id || undefined, + clientSecret: parsed.client_secret || undefined, + }; + } + } catch (e) { + console.error("Failed to load pre-registered credentials:", e); + } + return {}; +} + +/** + * Build well-known resource metadata URL from server URL (RFC 9728) + */ +export function buildResourceMetadataUrl(serverUrl: string): string { + const url = new URL(serverUrl); + if (url.pathname !== "/" && url.pathname !== "") { + const pathname = url.pathname.endsWith("/") + ? url.pathname.slice(0, -1) + : url.pathname; + return new URL( + `/.well-known/oauth-protected-resource${pathname}`, + url.origin, + ).toString(); + } + return new URL( + "/.well-known/oauth-protected-resource", + url.origin, + ).toString(); +} diff --git a/client/src/lib/oauth/state-machines/shared/types.ts b/client/src/lib/oauth/state-machines/shared/types.ts new file mode 100644 index 000000000..87b9c01f0 --- /dev/null +++ b/client/src/lib/oauth/state-machines/shared/types.ts @@ -0,0 +1,16 @@ +/** + * Shared types for OAuth state machines + */ + +/** + * Action definition for sequence diagram + * Used to build the visual representation of OAuth flow steps + */ +export interface DiagramAction { + id: string; + label: string; + description: string; + from: string; + to: string; + details?: Array<{ label: string; value: any }>; +} diff --git a/client/src/lib/oauth/state-machines/types.ts b/client/src/lib/oauth/state-machines/types.ts index 80c942fd6..8a0ae31e6 100644 --- a/client/src/lib/oauth/state-machines/types.ts +++ b/client/src/lib/oauth/state-machines/types.ts @@ -1,6 +1,5 @@ /** * Shared types for OAuth state machines - * Used by both 2025-06-18 and 2025-11-25 protocol implementations */ // OAuth flow steps based on MCP specification