Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 245 additions & 40 deletions client/src/components/AuthDebugger.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { useCallback, useMemo, useEffect } from "react";
import { useCallback, useMemo, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
import { AlertCircle } from "lucide-react";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types";
Expand All @@ -9,6 +12,7 @@ import { createProxyFetch } from "../lib/proxyFetch";
import { SESSION_KEYS } from "../lib/constants";
import { validateRedirectUrl } from "@/utils/urlValidation";
import type { InspectorConfig } from "../lib/configurationTypes";
import { exchangeClientCredentials } from "../lib/clientCredentialsAuth";

export interface AuthDebuggerProps {
serverUrl: string;
Expand Down Expand Up @@ -255,6 +259,92 @@ const AuthDebugger = ({
}
}, [serverUrl, updateAuthState]);

// ----- Client Credentials grant -----
// Form state is local: this flow is a single token request, not a multi-step
// state machine, so it doesn't need to live in shared AuthDebuggerState.
const [ccTokenEndpoint, setCcTokenEndpoint] = useState("");
const [ccClientId, setCcClientId] = useState("");
const [ccClientSecret, setCcClientSecret] = useState("");
const [ccScope, setCcScope] = useState("");
const [ccAuthMethod, setCcAuthMethod] = useState<"basic" | "body">("basic");

const handleClientCredentialsExchange = useCallback(async () => {
if (!serverUrl) {
updateAuthState({
statusMessage: {
type: "error",
message:
"Please enter a server URL in the sidebar before authenticating",
},
});
return;
}

if (!ccTokenEndpoint || !ccClientId || !ccClientSecret) {
updateAuthState({
statusMessage: {
type: "error",
message:
"Token Endpoint, Client ID, and Client Secret are required for client_credentials",
},
});
return;
}

updateAuthState({
isInitiatingAuth: true,
statusMessage: null,
latestError: null,
});

try {
const tokens = await exchangeClientCredentials(
{
tokenEndpoint: ccTokenEndpoint,
clientId: ccClientId,
clientSecret: ccClientSecret,
scope: ccScope || undefined,
authMethod: ccAuthMethod,
},
fetchFn,
);

const provider = new DebugInspectorOAuthClientProvider(serverUrl);
provider.saveTokens(tokens);

updateAuthState({
oauthTokens: tokens,
oauthStep: "complete",
statusMessage: {
type: "success",
message:
"Obtained access token via client_credentials grant. It will be used automatically for server requests.",
},
});
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
updateAuthState({
latestError: error instanceof Error ? error : new Error(message),
statusMessage: {
type: "error",
message: `client_credentials flow failed: ${message}`,
},
});
} finally {
updateAuthState({ isInitiatingAuth: false });
}
}, [
serverUrl,
ccTokenEndpoint,
ccClientId,
ccClientSecret,
ccScope,
ccAuthMethod,
fetchFn,
updateAuthState,
]);

return (
<div className="w-full p-4">
<div className="flex justify-between items-center mb-6">
Expand All @@ -281,48 +371,163 @@ const AuthDebugger = ({
<StatusMessage message={authState.statusMessage} />
)}

<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>
{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 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>
)}

<Tabs defaultValue="authorization_code" className="w-full">
<TabsList>
<TabsTrigger value="authorization_code">
Authorization Code
</TabsTrigger>
<TabsTrigger value="client_credentials">
Client Credentials
</TabsTrigger>
</TabsList>

<TabsContent
value="authorization_code"
className="space-y-4 pt-4"
>
<div className="flex gap-4 flex-wrap">
<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>
</TabsContent>

<TabsContent
value="client_credentials"
className="space-y-4 pt-4"
>
<p className="text-sm text-muted-foreground">
OAuth 2.0 client_credentials grant. Use this for
service-to-service MCP servers (e.g. behind an API gateway)
where the inspector is the client.
</p>

<div className="grid gap-3">
<div className="grid gap-1.5">
<Label htmlFor="cc-token-endpoint">
Token Endpoint
</Label>
<Input
id="cc-token-endpoint"
type="url"
autoComplete="off"
placeholder="https://auth.example.com/oauth/token"
value={ccTokenEndpoint}
onChange={(e) => setCcTokenEndpoint(e.target.value)}
/>
</div>

<div className="grid gap-1.5">
<Label htmlFor="cc-client-id">Client ID</Label>
<Input
id="cc-client-id"
autoComplete="off"
value={ccClientId}
onChange={(e) => setCcClientId(e.target.value)}
/>
</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 className="grid gap-1.5">
<Label htmlFor="cc-client-secret">Client Secret</Label>
<Input
id="cc-client-secret"
type="password"
autoComplete="off"
value={ccClientSecret}
onChange={(e) => setCcClientSecret(e.target.value)}
/>
</div>

<div className="grid gap-1.5">
<Label htmlFor="cc-scope">Scope (optional)</Label>
<Input
id="cc-scope"
autoComplete="off"
placeholder="space-separated, e.g. read write"
value={ccScope}
onChange={(e) => setCcScope(e.target.value)}
/>
</div>

<div className="grid gap-1.5">
<Label htmlFor="cc-auth-method">
Client Authentication
</Label>
<select
id="cc-auth-method"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={ccAuthMethod}
onChange={(e) =>
setCcAuthMethod(
e.target.value === "body" ? "body" : "basic",
)
}
>
<option value="basic">
HTTP Basic (Authorization header)
</option>
<option value="body">
Request body (client_id + client_secret)
</option>
</select>
<p className="text-xs text-muted-foreground">
Most authorization servers accept Basic. Switch to body
if your server requires credentials in the form body.
</p>
</div>
</div>

<div className="flex gap-3 flex-wrap">
<Button
onClick={handleClientCredentialsExchange}
disabled={authState.isInitiatingAuth}
>
{authState.isInitiatingAuth
? "Requesting token..."
: "Request Token"}
</Button>

<Button variant="outline" onClick={handleClearOAuth}>
Clear OAuth State
</Button>
</div>
</TabsContent>
</Tabs>
</div>

<OAuthFlowProgress
Expand Down
Loading