Merge branch 'main' into main
This commit is contained in:
260
client/src/components/AuthDebugger.tsx
Normal file
260
client/src/components/AuthDebugger.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
|
||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AuthDebuggerState } from "../lib/auth-types";
|
||||
import { OAuthFlowProgress } from "./OAuthFlowProgress";
|
||||
import { OAuthStateMachine } from "../lib/oauth-state-machine";
|
||||
|
||||
export interface AuthDebuggerProps {
|
||||
serverUrl: string;
|
||||
onBack: () => void;
|
||||
authState: AuthDebuggerState;
|
||||
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
|
||||
}
|
||||
|
||||
interface StatusMessageProps {
|
||||
message: { type: "error" | "success" | "info"; message: string };
|
||||
}
|
||||
|
||||
const StatusMessage = ({ message }: StatusMessageProps) => {
|
||||
let bgColor: string;
|
||||
let textColor: string;
|
||||
let borderColor: string;
|
||||
|
||||
switch (message.type) {
|
||||
case "error":
|
||||
bgColor = "bg-red-50";
|
||||
textColor = "text-red-700";
|
||||
borderColor = "border-red-200";
|
||||
break;
|
||||
case "success":
|
||||
bgColor = "bg-green-50";
|
||||
textColor = "text-green-700";
|
||||
borderColor = "border-green-200";
|
||||
break;
|
||||
case "info":
|
||||
default:
|
||||
bgColor = "bg-blue-50";
|
||||
textColor = "text-blue-700";
|
||||
borderColor = "border-blue-200";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-3 rounded-md border ${bgColor} ${borderColor} ${textColor} mb-4`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<p className="text-sm">{message.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AuthDebugger = ({
|
||||
serverUrl: serverUrl,
|
||||
onBack,
|
||||
authState,
|
||||
updateAuthState,
|
||||
}: AuthDebuggerProps) => {
|
||||
const startOAuthFlow = useCallback(() => {
|
||||
if (!serverUrl) {
|
||||
updateAuthState({
|
||||
statusMessage: {
|
||||
type: "error",
|
||||
message:
|
||||
"Please enter a server URL in the sidebar before authenticating",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateAuthState({
|
||||
oauthStep: "metadata_discovery",
|
||||
authorizationUrl: null,
|
||||
statusMessage: null,
|
||||
latestError: null,
|
||||
});
|
||||
}, [serverUrl, updateAuthState]);
|
||||
|
||||
const stateMachine = useMemo(
|
||||
() => new OAuthStateMachine(serverUrl, updateAuthState),
|
||||
[serverUrl, updateAuthState],
|
||||
);
|
||||
|
||||
const proceedToNextStep = useCallback(async () => {
|
||||
if (!serverUrl) return;
|
||||
|
||||
try {
|
||||
updateAuthState({
|
||||
isInitiatingAuth: true,
|
||||
statusMessage: null,
|
||||
latestError: null,
|
||||
});
|
||||
|
||||
await stateMachine.executeStep(authState);
|
||||
} catch (error) {
|
||||
console.error("OAuth flow error:", error);
|
||||
updateAuthState({
|
||||
latestError: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
} finally {
|
||||
updateAuthState({ isInitiatingAuth: false });
|
||||
}
|
||||
}, [serverUrl, authState, updateAuthState, stateMachine]);
|
||||
|
||||
const handleQuickOAuth = useCallback(async () => {
|
||||
if (!serverUrl) {
|
||||
updateAuthState({
|
||||
statusMessage: {
|
||||
type: "error",
|
||||
message:
|
||||
"Please enter a server URL in the sidebar before authenticating",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateAuthState({ isInitiatingAuth: true, statusMessage: null });
|
||||
try {
|
||||
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
|
||||
serverUrl,
|
||||
);
|
||||
await auth(serverAuthProvider, { serverUrl: serverUrl });
|
||||
updateAuthState({
|
||||
statusMessage: {
|
||||
type: "info",
|
||||
message: "Starting OAuth authentication process...",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("OAuth initialization error:", error);
|
||||
updateAuthState({
|
||||
statusMessage: {
|
||||
type: "error",
|
||||
message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
updateAuthState({ isInitiatingAuth: false });
|
||||
}
|
||||
}, [serverUrl, updateAuthState]);
|
||||
|
||||
const handleClearOAuth = useCallback(() => {
|
||||
if (serverUrl) {
|
||||
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
|
||||
serverUrl,
|
||||
);
|
||||
serverAuthProvider.clear();
|
||||
updateAuthState({
|
||||
oauthTokens: null,
|
||||
oauthStep: "metadata_discovery",
|
||||
latestError: null,
|
||||
oauthClientInfo: null,
|
||||
authorizationCode: "",
|
||||
validationError: null,
|
||||
oauthMetadata: null,
|
||||
statusMessage: {
|
||||
type: "success",
|
||||
message: "OAuth tokens cleared successfully",
|
||||
},
|
||||
});
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
updateAuthState({ statusMessage: null });
|
||||
}, 3000);
|
||||
}
|
||||
}, [serverUrl, updateAuthState]);
|
||||
|
||||
return (
|
||||
<div className="w-full p-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold">Authentication Settings</h2>
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
Back to Connect
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid w-full gap-2">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Configure authentication settings for your MCP server connection.
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border p-6 space-y-6">
|
||||
<h3 className="text-lg font-medium">OAuth Authentication</h3>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Use OAuth to securely authenticate with the MCP server.
|
||||
</p>
|
||||
|
||||
{authState.statusMessage && (
|
||||
<StatusMessage message={authState.statusMessage} />
|
||||
)}
|
||||
|
||||
{authState.loading ? (
|
||||
<p>Loading authentication status...</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{authState.oauthTokens && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Access Token:</p>
|
||||
<div className="bg-muted p-2 rounded-md text-xs overflow-x-auto">
|
||||
{authState.oauthTokens.access_token.substring(0, 25)}...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={startOAuthFlow}
|
||||
disabled={authState.isInitiatingAuth}
|
||||
>
|
||||
{authState.oauthTokens
|
||||
? "Guided Token Refresh"
|
||||
: "Guided OAuth Flow"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleQuickOAuth}
|
||||
disabled={authState.isInitiatingAuth}
|
||||
>
|
||||
{authState.isInitiatingAuth
|
||||
? "Initiating..."
|
||||
: authState.oauthTokens
|
||||
? "Quick Refresh"
|
||||
: "Quick OAuth Flow"}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={handleClearOAuth}>
|
||||
Clear OAuth State
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose "Guided" for step-by-step instructions or "Quick" for
|
||||
the standard automatic flow.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<OAuthFlowProgress
|
||||
serverUrl={serverUrl}
|
||||
authState={authState}
|
||||
updateAuthState={updateAuthState}
|
||||
proceedToNextStep={proceedToNextStep}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthDebugger;
|
||||
92
client/src/components/OAuthDebugCallback.tsx
Normal file
92
client/src/components/OAuthDebugCallback.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect } from "react";
|
||||
import { SESSION_KEYS } from "../lib/constants";
|
||||
import {
|
||||
generateOAuthErrorDescription,
|
||||
parseOAuthCallbackParams,
|
||||
} from "@/utils/oauthUtils.ts";
|
||||
|
||||
interface OAuthCallbackProps {
|
||||
onConnect: ({
|
||||
authorizationCode,
|
||||
errorMsg,
|
||||
}: {
|
||||
authorizationCode?: string;
|
||||
errorMsg?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => {
|
||||
useEffect(() => {
|
||||
let isProcessed = false;
|
||||
|
||||
const handleCallback = async () => {
|
||||
// Skip if we've already processed this callback
|
||||
if (isProcessed) {
|
||||
return;
|
||||
}
|
||||
isProcessed = true;
|
||||
|
||||
const params = parseOAuthCallbackParams(window.location.search);
|
||||
if (!params.successful) {
|
||||
const errorMsg = generateOAuthErrorDescription(params);
|
||||
onConnect({ errorMsg });
|
||||
return;
|
||||
}
|
||||
|
||||
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
|
||||
|
||||
// ServerURL isn't set, this can happen if we've opened the
|
||||
// authentication request in a new tab, so we don't have the same
|
||||
// session storage
|
||||
if (!serverUrl) {
|
||||
// If there's no server URL, we're likely in a new tab
|
||||
// Just display the code for manual copying
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params.code) {
|
||||
onConnect({ errorMsg: "Missing authorization code" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Instead of storing in sessionStorage, pass the code directly
|
||||
// to the auth state manager through onConnect
|
||||
onConnect({ authorizationCode: params.code });
|
||||
};
|
||||
|
||||
handleCallback().finally(() => {
|
||||
// Only redirect if we have the URL set, otherwise assume this was
|
||||
// in a new tab
|
||||
if (sessionStorage.getItem(SESSION_KEYS.SERVER_URL)) {
|
||||
window.history.replaceState({}, document.title, "/");
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isProcessed = true;
|
||||
};
|
||||
}, [onConnect]);
|
||||
|
||||
const callbackParams = parseOAuthCallbackParams(window.location.search);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="mt-4 p-4 bg-secondary rounded-md max-w-md">
|
||||
<p className="mb-2 text-sm">
|
||||
Please copy this authorization code and return to the Auth Debugger:
|
||||
</p>
|
||||
<code className="block p-2 bg-muted rounded-sm overflow-x-auto text-xs">
|
||||
{callbackParams.successful && "code" in callbackParams
|
||||
? callbackParams.code
|
||||
: `No code found: ${callbackParams.error}, ${callbackParams.error_description}`}
|
||||
</code>
|
||||
<p className="mt-4 text-xs text-muted-foreground">
|
||||
Close this tab and paste the code in the OAuth flow to complete
|
||||
authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthDebugCallback;
|
||||
259
client/src/components/OAuthFlowProgress.tsx
Normal file
259
client/src/components/OAuthFlowProgress.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types";
|
||||
import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
|
||||
|
||||
interface OAuthStepProps {
|
||||
label: string;
|
||||
isComplete: boolean;
|
||||
isCurrent: boolean;
|
||||
error?: Error | null;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const OAuthStepDetails = ({
|
||||
label,
|
||||
isComplete,
|
||||
isCurrent,
|
||||
error,
|
||||
children,
|
||||
}: OAuthStepProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`flex items-center p-2 rounded-md ${isCurrent ? "bg-accent" : ""}`}
|
||||
>
|
||||
{isComplete ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500 mr-2" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-muted-foreground mr-2" />
|
||||
)}
|
||||
<span className={`${isCurrent ? "font-medium" : ""}`}>{label}</span>
|
||||
</div>
|
||||
|
||||
{/* Show children if current step or complete and children exist */}
|
||||
{(isCurrent || isComplete) && children && (
|
||||
<div className="ml-7 mt-1">{children}</div>
|
||||
)}
|
||||
|
||||
{/* Display error if current step and an error exists */}
|
||||
{isCurrent && error && (
|
||||
<div className="ml-7 mt-2 p-3 border border-red-300 bg-red-50 rounded-md">
|
||||
<p className="text-sm font-medium text-red-700">Error:</p>
|
||||
<p className="text-xs text-red-600 mt-1">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface OAuthFlowProgressProps {
|
||||
serverUrl: string;
|
||||
authState: AuthDebuggerState;
|
||||
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
|
||||
proceedToNextStep: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const OAuthFlowProgress = ({
|
||||
serverUrl,
|
||||
authState,
|
||||
updateAuthState,
|
||||
proceedToNextStep,
|
||||
}: OAuthFlowProgressProps) => {
|
||||
const provider = new DebugInspectorOAuthClientProvider(serverUrl);
|
||||
|
||||
const steps: Array<OAuthStep> = [
|
||||
"metadata_discovery",
|
||||
"client_registration",
|
||||
"authorization_redirect",
|
||||
"authorization_code",
|
||||
"token_request",
|
||||
"complete",
|
||||
];
|
||||
const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep);
|
||||
|
||||
// Helper to get step props
|
||||
const getStepProps = (stepName: OAuthStep) => ({
|
||||
isComplete:
|
||||
currentStepIdx > steps.indexOf(stepName) ||
|
||||
currentStepIdx === steps.length - 1, // last step is "complete"
|
||||
isCurrent: authState.oauthStep === stepName,
|
||||
error: authState.oauthStep === stepName ? authState.latestError : null,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-6 space-y-4 mt-4">
|
||||
<h3 className="text-lg font-medium">OAuth Flow Progress</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Follow these steps to complete OAuth authentication with the server.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<OAuthStepDetails
|
||||
label="Metadata Discovery"
|
||||
{...getStepProps("metadata_discovery")}
|
||||
>
|
||||
{provider.getServerMetadata() && (
|
||||
<details className="text-xs mt-2">
|
||||
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||
Retrieved OAuth Metadata from {serverUrl}
|
||||
/.well-known/oauth-authorization-server
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||
{JSON.stringify(provider.getServerMetadata(), null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</OAuthStepDetails>
|
||||
|
||||
<OAuthStepDetails
|
||||
label="Client Registration"
|
||||
{...getStepProps("client_registration")}
|
||||
>
|
||||
{authState.oauthClientInfo && (
|
||||
<details className="text-xs mt-2">
|
||||
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||
Registered Client Information
|
||||
</summary>
|
||||
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||
{JSON.stringify(authState.oauthClientInfo, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</OAuthStepDetails>
|
||||
|
||||
<OAuthStepDetails
|
||||
label="Preparing Authorization"
|
||||
{...getStepProps("authorization_redirect")}
|
||||
>
|
||||
{authState.authorizationUrl && (
|
||||
<div className="mt-2 p-3 border rounded-md bg-muted">
|
||||
<p className="font-medium mb-2 text-sm">Authorization URL:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs break-all">
|
||||
{authState.authorizationUrl}
|
||||
</p>
|
||||
<a
|
||||
href={authState.authorizationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center text-blue-500 hover:text-blue-700"
|
||||
aria-label="Open authorization URL in new tab"
|
||||
title="Open authorization URL"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Click the link to authorize in your browser. After
|
||||
authorization, you'll be redirected back to continue the flow.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</OAuthStepDetails>
|
||||
|
||||
<OAuthStepDetails
|
||||
label="Request Authorization and acquire authorization code"
|
||||
{...getStepProps("authorization_code")}
|
||||
>
|
||||
<div className="mt-3">
|
||||
<label
|
||||
htmlFor="authCode"
|
||||
className="block text-sm font-medium mb-1"
|
||||
>
|
||||
Authorization Code
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="authCode"
|
||||
value={authState.authorizationCode}
|
||||
onChange={(e) => {
|
||||
updateAuthState({
|
||||
authorizationCode: e.target.value,
|
||||
validationError: null,
|
||||
});
|
||||
}}
|
||||
placeholder="Enter the code from the authorization server"
|
||||
className={`flex h-9 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
||||
authState.validationError ? "border-red-500" : "border-input"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{authState.validationError && (
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
{authState.validationError}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Once you've completed authorization in the link, paste the code
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
</OAuthStepDetails>
|
||||
|
||||
<OAuthStepDetails
|
||||
label="Token Request"
|
||||
{...getStepProps("token_request")}
|
||||
>
|
||||
{authState.oauthMetadata && (
|
||||
<details className="text-xs mt-2">
|
||||
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||
Token Request Details
|
||||
</summary>
|
||||
<div className="mt-2 p-2 bg-muted rounded-md">
|
||||
<p className="font-medium">Token Endpoint:</p>
|
||||
<code className="block mt-1 text-xs overflow-x-auto">
|
||||
{authState.oauthMetadata.token_endpoint}
|
||||
</code>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</OAuthStepDetails>
|
||||
|
||||
<OAuthStepDetails
|
||||
label="Authentication Complete"
|
||||
{...getStepProps("complete")}
|
||||
>
|
||||
{authState.oauthTokens && (
|
||||
<details className="text-xs mt-2">
|
||||
<summary className="cursor-pointer text-muted-foreground font-medium">
|
||||
Access Tokens
|
||||
</summary>
|
||||
<p className="mt-1 text-sm">
|
||||
Authentication successful! You can now use the authenticated
|
||||
connection. These tokens will be used automatically for server
|
||||
requests.
|
||||
</p>
|
||||
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
|
||||
{JSON.stringify(authState.oauthTokens, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</OAuthStepDetails>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-4">
|
||||
{authState.oauthStep !== "complete" && (
|
||||
<>
|
||||
<Button
|
||||
onClick={proceedToNextStep}
|
||||
disabled={authState.isInitiatingAuth}
|
||||
>
|
||||
{authState.isInitiatingAuth ? "Processing..." : "Continue"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authState.oauthStep === "authorization_redirect" &&
|
||||
authState.authorizationUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open(authState.authorizationUrl!, "_blank")}
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
382
client/src/components/__tests__/AuthDebugger.test.tsx
Normal file
382
client/src/components/__tests__/AuthDebugger.test.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
act,
|
||||
} from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||
import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { SESSION_KEYS } from "@/lib/constants";
|
||||
|
||||
const mockOAuthTokens = {
|
||||
access_token: "test_access_token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
refresh_token: "test_refresh_token",
|
||||
scope: "test_scope",
|
||||
};
|
||||
|
||||
const mockOAuthMetadata = {
|
||||
issuer: "https://oauth.example.com",
|
||||
authorization_endpoint: "https://oauth.example.com/authorize",
|
||||
token_endpoint: "https://oauth.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
};
|
||||
|
||||
const mockOAuthClientInfo = {
|
||||
client_id: "test_client_id",
|
||||
client_secret: "test_client_secret",
|
||||
redirect_uris: ["http://localhost:3000/oauth/callback/debug"],
|
||||
};
|
||||
|
||||
// Mock MCP SDK functions - must be before imports
|
||||
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
auth: jest.fn(),
|
||||
discoverOAuthMetadata: jest.fn(),
|
||||
registerClient: jest.fn(),
|
||||
startAuthorization: jest.fn(),
|
||||
exchangeAuthorization: jest.fn(),
|
||||
}));
|
||||
|
||||
// Import the functions to get their types
|
||||
import {
|
||||
discoverOAuthMetadata,
|
||||
registerClient,
|
||||
startAuthorization,
|
||||
exchangeAuthorization,
|
||||
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
// Type the mocked functions properly
|
||||
const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction<
|
||||
typeof discoverOAuthMetadata
|
||||
>;
|
||||
const mockRegisterClient = registerClient as jest.MockedFunction<
|
||||
typeof registerClient
|
||||
>;
|
||||
const mockStartAuthorization = startAuthorization as jest.MockedFunction<
|
||||
typeof startAuthorization
|
||||
>;
|
||||
const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction<
|
||||
typeof exchangeAuthorization
|
||||
>;
|
||||
|
||||
const sessionStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
};
|
||||
Object.defineProperty(window, "sessionStorage", {
|
||||
value: sessionStorageMock,
|
||||
});
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
origin: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
|
||||
describe("AuthDebugger", () => {
|
||||
const defaultAuthState = {
|
||||
isInitiatingAuth: false,
|
||||
oauthTokens: null,
|
||||
loading: false,
|
||||
oauthStep: "metadata_discovery" as const,
|
||||
oauthMetadata: null,
|
||||
oauthClientInfo: null,
|
||||
authorizationUrl: null,
|
||||
authorizationCode: "",
|
||||
latestError: null,
|
||||
statusMessage: null,
|
||||
validationError: null,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
serverUrl: "https://example.com",
|
||||
onBack: jest.fn(),
|
||||
authState: defaultAuthState,
|
||||
updateAuthState: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
sessionStorageMock.getItem.mockReturnValue(null);
|
||||
|
||||
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
|
||||
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
|
||||
mockStartAuthorization.mockImplementation(async (_sseUrl, options) => {
|
||||
const authUrl = new URL("https://oauth.example.com/authorize");
|
||||
|
||||
if (options.scope) {
|
||||
authUrl.searchParams.set("scope", options.scope);
|
||||
}
|
||||
|
||||
return {
|
||||
authorizationUrl: authUrl,
|
||||
codeVerifier: "test_verifier",
|
||||
};
|
||||
});
|
||||
mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens);
|
||||
});
|
||||
|
||||
const renderAuthDebugger = (props: Partial<AuthDebuggerProps> = {}) => {
|
||||
const mergedProps = {
|
||||
...defaultProps,
|
||||
...props,
|
||||
authState: { ...defaultAuthState, ...(props.authState || {}) },
|
||||
};
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<AuthDebugger {...mergedProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("Initial Rendering", () => {
|
||||
it("should render the component with correct title", async () => {
|
||||
await act(async () => {
|
||||
renderAuthDebugger();
|
||||
});
|
||||
expect(screen.getByText("Authentication Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onBack when Back button is clicked", async () => {
|
||||
const onBack = jest.fn();
|
||||
await act(async () => {
|
||||
renderAuthDebugger({ onBack });
|
||||
});
|
||||
fireEvent.click(screen.getByText("Back to Connect"));
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth Flow", () => {
|
||||
it("should start OAuth flow when 'Guided OAuth Flow' is clicked", async () => {
|
||||
await act(async () => {
|
||||
renderAuthDebugger();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Guided OAuth Flow"));
|
||||
});
|
||||
|
||||
expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error when OAuth flow is started without sseUrl", async () => {
|
||||
const updateAuthState = jest.fn();
|
||||
await act(async () => {
|
||||
renderAuthDebugger({ serverUrl: "", updateAuthState });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Guided OAuth Flow"));
|
||||
});
|
||||
|
||||
expect(updateAuthState).toHaveBeenCalledWith({
|
||||
statusMessage: {
|
||||
type: "error",
|
||||
message:
|
||||
"Please enter a server URL in the sidebar before authenticating",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session Storage Integration", () => {
|
||||
it("should load OAuth tokens from session storage", async () => {
|
||||
// Mock the specific key for tokens with server URL
|
||||
sessionStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === "[https://example.com] mcp_tokens") {
|
||||
return JSON.stringify(mockOAuthTokens);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderAuthDebugger({
|
||||
authState: {
|
||||
...defaultAuthState,
|
||||
oauthTokens: mockOAuthTokens,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Access Token:/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle errors loading OAuth tokens from session storage", async () => {
|
||||
// Mock console to avoid cluttering test output
|
||||
const originalError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
// Mock getItem to return invalid JSON for tokens
|
||||
sessionStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === "[https://example.com] mcp_tokens") {
|
||||
return "invalid json";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderAuthDebugger();
|
||||
});
|
||||
|
||||
// Component should still render despite the error
|
||||
expect(screen.getByText("Authentication Settings")).toBeInTheDocument();
|
||||
|
||||
// Restore console.error
|
||||
console.error = originalError;
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth State Management", () => {
|
||||
it("should clear OAuth state when Clear button is clicked", async () => {
|
||||
const updateAuthState = jest.fn();
|
||||
// Mock the session storage to return tokens for the specific key
|
||||
sessionStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === "[https://example.com] mcp_tokens") {
|
||||
return JSON.stringify(mockOAuthTokens);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderAuthDebugger({
|
||||
authState: {
|
||||
...defaultAuthState,
|
||||
oauthTokens: mockOAuthTokens,
|
||||
},
|
||||
updateAuthState,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Clear OAuth State"));
|
||||
});
|
||||
|
||||
expect(updateAuthState).toHaveBeenCalledWith({
|
||||
oauthTokens: null,
|
||||
oauthStep: "metadata_discovery",
|
||||
latestError: null,
|
||||
oauthClientInfo: null,
|
||||
oauthMetadata: null,
|
||||
authorizationCode: "",
|
||||
validationError: null,
|
||||
statusMessage: {
|
||||
type: "success",
|
||||
message: "OAuth tokens cleared successfully",
|
||||
},
|
||||
});
|
||||
|
||||
// Verify session storage was cleared
|
||||
expect(sessionStorageMock.removeItem).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth Flow Steps", () => {
|
||||
it("should handle OAuth flow step progression", async () => {
|
||||
const updateAuthState = jest.fn();
|
||||
await act(async () => {
|
||||
renderAuthDebugger({
|
||||
updateAuthState,
|
||||
authState: {
|
||||
...defaultAuthState,
|
||||
isInitiatingAuth: false, // Changed to false so button is enabled
|
||||
oauthStep: "metadata_discovery",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Verify metadata discovery step
|
||||
expect(screen.getByText("Metadata Discovery")).toBeInTheDocument();
|
||||
|
||||
// Click Continue - this should trigger metadata discovery
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Continue"));
|
||||
});
|
||||
|
||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
||||
"https://example.com",
|
||||
);
|
||||
});
|
||||
|
||||
// Setup helper for OAuth authorization tests
|
||||
const setupAuthorizationUrlTest = async (metadata: OAuthMetadata) => {
|
||||
const updateAuthState = jest.fn();
|
||||
|
||||
// Mock the session storage to return metadata
|
||||
sessionStorageMock.getItem.mockImplementation((key) => {
|
||||
if (key === `[https://example.com] ${SESSION_KEYS.SERVER_METADATA}`) {
|
||||
return JSON.stringify(metadata);
|
||||
}
|
||||
if (
|
||||
key === `[https://example.com] ${SESSION_KEYS.CLIENT_INFORMATION}`
|
||||
) {
|
||||
return JSON.stringify(mockOAuthClientInfo);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderAuthDebugger({
|
||||
updateAuthState,
|
||||
authState: {
|
||||
...defaultAuthState,
|
||||
isInitiatingAuth: false,
|
||||
oauthStep: "authorization_redirect",
|
||||
oauthMetadata: metadata,
|
||||
oauthClientInfo: mockOAuthClientInfo,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Click Continue to trigger authorization
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Continue"));
|
||||
});
|
||||
|
||||
return updateAuthState;
|
||||
};
|
||||
|
||||
it("should include scope in authorization URL when scopes_supported is present", async () => {
|
||||
const metadataWithScopes = {
|
||||
...mockOAuthMetadata,
|
||||
scopes_supported: ["read", "write", "admin"],
|
||||
};
|
||||
|
||||
const updateAuthState =
|
||||
await setupAuthorizationUrlTest(metadataWithScopes);
|
||||
|
||||
// Wait for the updateAuthState to be called
|
||||
await waitFor(() => {
|
||||
expect(updateAuthState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authorizationUrl: expect.stringContaining("scope="),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not include scope in authorization URL when scopes_supported is not present", async () => {
|
||||
const updateAuthState =
|
||||
await setupAuthorizationUrlTest(mockOAuthMetadata);
|
||||
|
||||
// Wait for the updateAuthState to be called
|
||||
await waitFor(() => {
|
||||
expect(updateAuthState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authorizationUrl: expect.not.stringContaining("scope="),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user