Merge branch 'main' into parse-tool-arg
This commit is contained in:
commit
0b4e395f2b
26 changed files with 2142 additions and 679 deletions
2
.husky/pre-commit
Normal file
2
.husky/pre-commit
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
npx lint-staged
|
||||||
|
git update-index --again
|
||||||
10
README.md
10
README.md
|
|
@ -166,6 +166,16 @@ If you need to disable authentication (NOT RECOMMENDED), you can set the `DANGER
|
||||||
DANGEROUSLY_OMIT_AUTH=true npm start
|
DANGEROUSLY_OMIT_AUTH=true npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🚨 WARNING 🚨**
|
||||||
|
|
||||||
|
Disabling authentication with `DANGEROUSLY_OMIT_AUTH` is incredibly dangerous! Disabling auth leaves your machine open to attack not just when exposed to the public internet, but also **via your web browser**. Meaning, visiting a malicious website OR viewing a malicious advertizement could allow an attacker to remotely compromise your computer. Do not disable this feature unless you truly understand the risks.
|
||||||
|
|
||||||
|
Read more about the risks of this vulnerability on Oligo's blog: [Critical RCE Vulnerability in Anthropic MCP Inspector - CVE-2025-49596](https://www.oligo.security/blog/critical-rce-vulnerability-in-anthropic-mcp-inspector-cve-2025-49596)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server:
|
You can also set the token via the `MCP_PROXY_AUTH_TOKEN` environment variable when starting the server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-cli",
|
"name": "@modelcontextprotocol/inspector-cli",
|
||||||
"version": "0.16.3",
|
"version": "0.16.5",
|
||||||
"description": "CLI for the Model Context Protocol inspector",
|
"description": "CLI for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {},
|
"devDependencies": {},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.17.0",
|
"@modelcontextprotocol/sdk": "^1.17.3",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"spawn-rx": "^5.1.2"
|
"spawn-rx": "^5.1.2"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,8 @@ function createStdioTransport(options: TransportOptions): Transport {
|
||||||
const defaultEnv = getDefaultEnvironment();
|
const defaultEnv = getDefaultEnvironment();
|
||||||
|
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
...processEnv,
|
|
||||||
...defaultEnv,
|
...defaultEnv,
|
||||||
|
...processEnv,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { cmd: actualCommand, args: actualArgs } = findActualExecutable(
|
const { cmd: actualCommand, args: actualArgs } = findActualExecutable(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@modelcontextprotocol/inspector-client",
|
"name": "@modelcontextprotocol/inspector-client",
|
||||||
"version": "0.16.3",
|
"version": "0.16.5",
|
||||||
"description": "Client-side application for the Model Context Protocol inspector",
|
"description": "Client-side application for the Model Context Protocol inspector",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
"cleanup:e2e": "node e2e/global-teardown.js"
|
"cleanup:e2e": "node e2e/global-teardown.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.17.0",
|
"@modelcontextprotocol/sdk": "^1.17.3",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.3",
|
"@radix-ui/react-dialog": "^1.1.3",
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ import {
|
||||||
useDraggablePane,
|
useDraggablePane,
|
||||||
useDraggableSidebar,
|
useDraggableSidebar,
|
||||||
} from "./lib/hooks/useDraggablePane";
|
} from "./lib/hooks/useDraggablePane";
|
||||||
import { StdErrNotification } from "./lib/notificationTypes";
|
|
||||||
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -106,9 +105,6 @@ const App = () => {
|
||||||
>(getInitialTransportType);
|
>(getInitialTransportType);
|
||||||
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
||||||
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
||||||
const [stdErrNotifications, setStdErrNotifications] = useState<
|
|
||||||
StdErrNotification[]
|
|
||||||
>([]);
|
|
||||||
const [roots, setRoots] = useState<Root[]>([]);
|
const [roots, setRoots] = useState<Root[]>([]);
|
||||||
const [env, setEnv] = useState<Record<string, string>>({});
|
const [env, setEnv] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
|
@ -224,12 +220,6 @@ const App = () => {
|
||||||
onNotification: (notification) => {
|
onNotification: (notification) => {
|
||||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||||
},
|
},
|
||||||
onStdErrNotification: (notification) => {
|
|
||||||
setStdErrNotifications((prev) => [
|
|
||||||
...prev,
|
|
||||||
notification as StdErrNotification,
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
onPendingRequest: (request, resolve, reject) => {
|
onPendingRequest: (request, resolve, reject) => {
|
||||||
setPendingSampleRequests((prev) => [
|
setPendingSampleRequests((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -757,10 +747,6 @@ const App = () => {
|
||||||
setLogLevel(level);
|
setLogLevel(level);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearStdErrNotifications = () => {
|
|
||||||
setStdErrNotifications([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AuthDebuggerWrapper = () => (
|
const AuthDebuggerWrapper = () => (
|
||||||
<TabsContent value="auth">
|
<TabsContent value="auth">
|
||||||
<AuthDebugger
|
<AuthDebugger
|
||||||
|
|
@ -829,11 +815,9 @@ const App = () => {
|
||||||
setOauthScope={setOauthScope}
|
setOauthScope={setOauthScope}
|
||||||
onConnect={connectMcpServer}
|
onConnect={connectMcpServer}
|
||||||
onDisconnect={disconnectMcpServer}
|
onDisconnect={disconnectMcpServer}
|
||||||
stdErrNotifications={stdErrNotifications}
|
|
||||||
logLevel={logLevel}
|
logLevel={logLevel}
|
||||||
sendLogLevelRequest={sendLogLevelRequest}
|
sendLogLevelRequest={sendLogLevelRequest}
|
||||||
loggingSupported={!!serverCapabilities?.logging || false}
|
loggingSupported={!!serverCapabilities?.logging || false}
|
||||||
clearStdErrNotifications={clearStdErrNotifications}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleSidebarDragStart}
|
onMouseDown={handleSidebarDragStart}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types";
|
||||||
import { OAuthFlowProgress } from "./OAuthFlowProgress";
|
import { OAuthFlowProgress } from "./OAuthFlowProgress";
|
||||||
import { OAuthStateMachine } from "../lib/oauth-state-machine";
|
import { OAuthStateMachine } from "../lib/oauth-state-machine";
|
||||||
import { SESSION_KEYS } from "../lib/constants";
|
import { SESSION_KEYS } from "../lib/constants";
|
||||||
|
import { validateRedirectUrl } from "@/utils/urlValidation";
|
||||||
|
|
||||||
export interface AuthDebuggerProps {
|
export interface AuthDebuggerProps {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
|
|
@ -163,6 +164,23 @@ const AuthDebugger = ({
|
||||||
currentState.oauthStep === "authorization_code" &&
|
currentState.oauthStep === "authorization_code" &&
|
||||||
currentState.authorizationUrl
|
currentState.authorizationUrl
|
||||||
) {
|
) {
|
||||||
|
// Validate the URL before redirecting
|
||||||
|
try {
|
||||||
|
validateRedirectUrl(currentState.authorizationUrl);
|
||||||
|
} catch (error) {
|
||||||
|
updateAuthState({
|
||||||
|
...currentState,
|
||||||
|
isInitiatingAuth: false,
|
||||||
|
latestError:
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
statusMessage: {
|
||||||
|
type: "error",
|
||||||
|
message: `Invalid authorization URL: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Store the current auth state before redirecting
|
// Store the current auth state before redirecting
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
SESSION_KEYS.AUTH_DEBUGGER_STATE,
|
SESSION_KEYS.AUTH_DEBUGGER_STATE,
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,17 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => {
|
||||||
if (storedState) {
|
if (storedState) {
|
||||||
try {
|
try {
|
||||||
restoredState = JSON.parse(storedState);
|
restoredState = JSON.parse(storedState);
|
||||||
|
if (restoredState && typeof restoredState.resource === "string") {
|
||||||
|
restoredState.resource = new URL(restoredState.resource);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
restoredState &&
|
||||||
|
typeof restoredState.authorizationUrl === "string"
|
||||||
|
) {
|
||||||
|
restoredState.authorizationUrl = new URL(
|
||||||
|
restoredState.authorizationUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
// Clean up the stored state
|
// Clean up the stored state
|
||||||
sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE);
|
sessionStorage.removeItem(SESSION_KEYS.AUTH_DEBUGGER_STATE);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { Button } from "./ui/button";
|
||||||
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
|
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
|
import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
import { validateRedirectUrl } from "@/utils/urlValidation";
|
||||||
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
|
|
||||||
interface OAuthStepProps {
|
interface OAuthStepProps {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -71,6 +73,7 @@ export const OAuthFlowProgress = ({
|
||||||
updateAuthState,
|
updateAuthState,
|
||||||
proceedToNextStep,
|
proceedToNextStep,
|
||||||
}: OAuthFlowProgressProps) => {
|
}: OAuthFlowProgressProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
const provider = useMemo(
|
const provider = useMemo(
|
||||||
() => new DebugInspectorOAuthClientProvider(serverUrl),
|
() => new DebugInspectorOAuthClientProvider(serverUrl),
|
||||||
[serverUrl],
|
[serverUrl],
|
||||||
|
|
@ -239,16 +242,32 @@ export const OAuthFlowProgress = ({
|
||||||
<p className="text-xs break-all">
|
<p className="text-xs break-all">
|
||||||
{authState.authorizationUrl}
|
{authState.authorizationUrl}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<button
|
||||||
href={authState.authorizationUrl}
|
onClick={() => {
|
||||||
target="_blank"
|
try {
|
||||||
rel="noopener noreferrer"
|
validateRedirectUrl(authState.authorizationUrl!);
|
||||||
|
window.open(
|
||||||
|
authState.authorizationUrl!,
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Invalid URL",
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "The authorization URL is not valid",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="flex items-center text-blue-500 hover:text-blue-700"
|
className="flex items-center text-blue-500 hover:text-blue-700"
|
||||||
aria-label="Open authorization URL in new tab"
|
aria-label="Open authorization URL in new tab"
|
||||||
title="Open authorization URL"
|
title="Open authorization URL"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
Click the link to authorize in your browser. After
|
Click the link to authorize in your browser. After
|
||||||
|
|
@ -354,7 +373,21 @@ export const OAuthFlowProgress = ({
|
||||||
authState.authorizationUrl && (
|
authState.authorizationUrl && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => window.open(authState.authorizationUrl!, "_blank")}
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
validateRedirectUrl(authState.authorizationUrl!);
|
||||||
|
window.open(authState.authorizationUrl!, "_blank");
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Invalid URL",
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "The authorization URL is not valid",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Open in New Tab
|
Open in New Tab
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { StdErrNotification } from "@/lib/notificationTypes";
|
|
||||||
import {
|
import {
|
||||||
LoggingLevel,
|
LoggingLevel,
|
||||||
LoggingLevelSchema,
|
LoggingLevelSchema,
|
||||||
|
|
@ -62,8 +61,6 @@ interface SidebarProps {
|
||||||
setOauthScope: (scope: string) => void;
|
setOauthScope: (scope: string) => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
onDisconnect: () => void;
|
onDisconnect: () => void;
|
||||||
stdErrNotifications: StdErrNotification[];
|
|
||||||
clearStdErrNotifications: () => void;
|
|
||||||
logLevel: LoggingLevel;
|
logLevel: LoggingLevel;
|
||||||
sendLogLevelRequest: (level: LoggingLevel) => void;
|
sendLogLevelRequest: (level: LoggingLevel) => void;
|
||||||
loggingSupported: boolean;
|
loggingSupported: boolean;
|
||||||
|
|
@ -93,8 +90,6 @@ const Sidebar = ({
|
||||||
setOauthScope,
|
setOauthScope,
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
stdErrNotifications,
|
|
||||||
clearStdErrNotifications,
|
|
||||||
logLevel,
|
logLevel,
|
||||||
sendLogLevelRequest,
|
sendLogLevelRequest,
|
||||||
loggingSupported,
|
loggingSupported,
|
||||||
|
|
@ -268,6 +263,7 @@ const Sidebar = ({
|
||||||
placeholder="Command"
|
placeholder="Command"
|
||||||
value={command}
|
value={command}
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
|
onBlur={(e) => setCommand(e.target.value.trim())}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -760,36 +756,6 @@ const Sidebar = ({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{stdErrNotifications.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h3 className="text-sm font-medium">
|
|
||||||
Error output from MCP server
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearStdErrNotifications}
|
|
||||||
className="h-8 px-2"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 max-h-80 overflow-y-auto">
|
|
||||||
{stdErrNotifications.map((notification, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="text-sm text-red-500 font-mono py-2 border-b border-gray-200 last:border-b-0"
|
|
||||||
>
|
|
||||||
{notification.params.content}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ const mockOAuthMetadata = {
|
||||||
token_endpoint: "https://oauth.example.com/token",
|
token_endpoint: "https://oauth.example.com/token",
|
||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
grant_types_supported: ["authorization_code"],
|
grant_types_supported: ["authorization_code"],
|
||||||
|
scopes_supported: ["read", "write"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockOAuthClientInfo = {
|
const mockOAuthClientInfo = {
|
||||||
|
|
@ -36,7 +37,7 @@ const mockOAuthClientInfo = {
|
||||||
// Mock MCP SDK functions - must be before imports
|
// Mock MCP SDK functions - must be before imports
|
||||||
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||||
auth: jest.fn(),
|
auth: jest.fn(),
|
||||||
discoverOAuthMetadata: jest.fn(),
|
discoverAuthorizationServerMetadata: jest.fn(),
|
||||||
registerClient: jest.fn(),
|
registerClient: jest.fn(),
|
||||||
startAuthorization: jest.fn(),
|
startAuthorization: jest.fn(),
|
||||||
exchangeAuthorization: jest.fn(),
|
exchangeAuthorization: jest.fn(),
|
||||||
|
|
@ -46,7 +47,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||||
|
|
||||||
// Import the functions to get their types
|
// Import the functions to get their types
|
||||||
import {
|
import {
|
||||||
discoverOAuthMetadata,
|
discoverAuthorizationServerMetadata,
|
||||||
registerClient,
|
registerClient,
|
||||||
startAuthorization,
|
startAuthorization,
|
||||||
exchangeAuthorization,
|
exchangeAuthorization,
|
||||||
|
|
@ -56,10 +57,62 @@ import {
|
||||||
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types";
|
import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types";
|
||||||
|
|
||||||
|
// Mock local auth module
|
||||||
|
jest.mock("@/lib/auth", () => ({
|
||||||
|
DebugInspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
|
||||||
|
tokens: jest.fn().mockImplementation(() => Promise.resolve(undefined)),
|
||||||
|
clear: jest.fn().mockImplementation(() => {
|
||||||
|
// Mock the real clear() behavior which removes items from sessionStorage
|
||||||
|
sessionStorage.removeItem("[https://example.com/mcp] mcp_tokens");
|
||||||
|
sessionStorage.removeItem("[https://example.com/mcp] mcp_client_info");
|
||||||
|
sessionStorage.removeItem(
|
||||||
|
"[https://example.com/mcp] mcp_server_metadata",
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
redirectUrl: "http://localhost:3000/oauth/callback/debug",
|
||||||
|
clientMetadata: {
|
||||||
|
redirect_uris: ["http://localhost:3000/oauth/callback/debug"],
|
||||||
|
token_endpoint_auth_method: "none",
|
||||||
|
grant_types: ["authorization_code", "refresh_token"],
|
||||||
|
response_types: ["code"],
|
||||||
|
client_name: "MCP Inspector",
|
||||||
|
},
|
||||||
|
clientInformation: jest.fn().mockImplementation(async () => {
|
||||||
|
const serverUrl = "https://example.com/mcp";
|
||||||
|
const preregisteredKey = `[${serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}`;
|
||||||
|
const preregisteredData = sessionStorage.getItem(preregisteredKey);
|
||||||
|
if (preregisteredData) {
|
||||||
|
return JSON.parse(preregisteredData);
|
||||||
|
}
|
||||||
|
const dynamicKey = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`;
|
||||||
|
const dynamicData = sessionStorage.getItem(dynamicKey);
|
||||||
|
if (dynamicData) {
|
||||||
|
return JSON.parse(dynamicData);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
saveClientInformation: jest.fn().mockImplementation((clientInfo) => {
|
||||||
|
const serverUrl = "https://example.com/mcp";
|
||||||
|
const key = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`;
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(clientInfo));
|
||||||
|
}),
|
||||||
|
saveTokens: jest.fn(),
|
||||||
|
redirectToAuthorization: jest.fn(),
|
||||||
|
saveCodeVerifier: jest.fn(),
|
||||||
|
codeVerifier: jest.fn(),
|
||||||
|
saveServerMetadata: jest.fn(),
|
||||||
|
getServerMetadata: jest.fn(),
|
||||||
|
})),
|
||||||
|
discoverScopes: jest.fn().mockResolvedValue("read write" as never),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { discoverScopes } from "@/lib/auth";
|
||||||
|
|
||||||
// Type the mocked functions properly
|
// Type the mocked functions properly
|
||||||
const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction<
|
const mockDiscoverAuthorizationServerMetadata =
|
||||||
typeof discoverOAuthMetadata
|
discoverAuthorizationServerMetadata as jest.MockedFunction<
|
||||||
>;
|
typeof discoverAuthorizationServerMetadata
|
||||||
|
>;
|
||||||
const mockRegisterClient = registerClient as jest.MockedFunction<
|
const mockRegisterClient = registerClient as jest.MockedFunction<
|
||||||
typeof registerClient
|
typeof registerClient
|
||||||
>;
|
>;
|
||||||
|
|
@ -74,6 +127,9 @@ const mockDiscoverOAuthProtectedResourceMetadata =
|
||||||
discoverOAuthProtectedResourceMetadata as jest.MockedFunction<
|
discoverOAuthProtectedResourceMetadata as jest.MockedFunction<
|
||||||
typeof discoverOAuthProtectedResourceMetadata
|
typeof discoverOAuthProtectedResourceMetadata
|
||||||
>;
|
>;
|
||||||
|
const mockDiscoverScopes = discoverScopes as jest.MockedFunction<
|
||||||
|
typeof discoverScopes
|
||||||
|
>;
|
||||||
|
|
||||||
const sessionStorageMock = {
|
const sessionStorageMock = {
|
||||||
getItem: jest.fn(),
|
getItem: jest.fn(),
|
||||||
|
|
@ -102,7 +158,15 @@ describe("AuthDebugger", () => {
|
||||||
// Suppress console errors in tests to avoid JSDOM navigation noise
|
// Suppress console errors in tests to avoid JSDOM navigation noise
|
||||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
|
// Set default mock behaviors with complete OAuth metadata
|
||||||
|
mockDiscoverAuthorizationServerMetadata.mockResolvedValue({
|
||||||
|
issuer: "https://oauth.example.com",
|
||||||
|
authorization_endpoint: "https://oauth.example.com/authorize",
|
||||||
|
token_endpoint: "https://oauth.example.com/token",
|
||||||
|
response_types_supported: ["code"],
|
||||||
|
grant_types_supported: ["authorization_code"],
|
||||||
|
scopes_supported: ["read", "write"],
|
||||||
|
});
|
||||||
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
|
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
|
||||||
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(
|
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(
|
||||||
new Error("No protected resource metadata found"),
|
new Error("No protected resource metadata found"),
|
||||||
|
|
@ -203,7 +267,7 @@ describe("AuthDebugger", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should first discover and save OAuth metadata
|
// Should first discover and save OAuth metadata
|
||||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
||||||
new URL("https://example.com/"),
|
new URL("https://example.com/"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -216,7 +280,7 @@ describe("AuthDebugger", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show error when quick OAuth flow fails to discover metadata", async () => {
|
it("should show error when quick OAuth flow fails to discover metadata", async () => {
|
||||||
mockDiscoverOAuthMetadata.mockRejectedValue(
|
mockDiscoverAuthorizationServerMetadata.mockRejectedValue(
|
||||||
new Error("Metadata discovery failed"),
|
new Error("Metadata discovery failed"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -362,7 +426,7 @@ describe("AuthDebugger", () => {
|
||||||
fireEvent.click(screen.getByText("Continue"));
|
fireEvent.click(screen.getByText("Continue"));
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
||||||
new URL("https://example.com/"),
|
new URL("https://example.com/"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -424,7 +488,24 @@ describe("AuthDebugger", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not include scope in authorization URL when scopes_supported is not present", async () => {
|
it("should include scope in authorization URL when scopes_supported is not present", async () => {
|
||||||
|
const updateAuthState =
|
||||||
|
await setupAuthorizationUrlTest(mockOAuthMetadata);
|
||||||
|
|
||||||
|
// Wait for the updateAuthState to be called
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(updateAuthState).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
authorizationUrl: expect.stringContaining("scope="),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should omit scope from authorization URL when discoverScopes returns undefined", async () => {
|
||||||
|
// Mock discoverScopes to return undefined (no scopes available)
|
||||||
|
mockDiscoverScopes.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
const updateAuthState =
|
const updateAuthState =
|
||||||
await setupAuthorizationUrlTest(mockOAuthMetadata);
|
await setupAuthorizationUrlTest(mockOAuthMetadata);
|
||||||
|
|
||||||
|
|
@ -439,6 +520,103 @@ describe("AuthDebugger", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Client Registration behavior", () => {
|
||||||
|
it("uses preregistered (static) client information without calling DCR", async () => {
|
||||||
|
const preregClientInfo = {
|
||||||
|
client_id: "static_client_id",
|
||||||
|
client_secret: "static_client_secret",
|
||||||
|
redirect_uris: ["http://localhost:3000/oauth/callback/debug"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return preregistered client info for the server-specific key
|
||||||
|
sessionStorageMock.getItem.mockImplementation((key) => {
|
||||||
|
if (
|
||||||
|
key ===
|
||||||
|
`[${defaultProps.serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}`
|
||||||
|
) {
|
||||||
|
return JSON.stringify(preregClientInfo);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAuthState = jest.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
renderAuthDebugger({
|
||||||
|
updateAuthState,
|
||||||
|
authState: {
|
||||||
|
...defaultAuthState,
|
||||||
|
isInitiatingAuth: false,
|
||||||
|
oauthStep: "client_registration",
|
||||||
|
oauthMetadata: mockOAuthMetadata as unknown as OAuthMetadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Proceed from client_registration → authorization_redirect
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText("Continue"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should NOT attempt dynamic client registration
|
||||||
|
expect(mockRegisterClient).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Should advance with the preregistered client info
|
||||||
|
expect(updateAuthState).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
oauthClientInfo: expect.objectContaining({
|
||||||
|
client_id: "static_client_id",
|
||||||
|
}),
|
||||||
|
oauthStep: "authorization_redirect",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to DCR when no static client information is available", async () => {
|
||||||
|
// No preregistered or dynamic client info present in session storage
|
||||||
|
sessionStorageMock.getItem.mockImplementation(() => null);
|
||||||
|
|
||||||
|
// DCR returns a new client
|
||||||
|
mockRegisterClient.mockResolvedValueOnce(mockOAuthClientInfo);
|
||||||
|
|
||||||
|
const updateAuthState = jest.fn();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
renderAuthDebugger({
|
||||||
|
updateAuthState,
|
||||||
|
authState: {
|
||||||
|
...defaultAuthState,
|
||||||
|
isInitiatingAuth: false,
|
||||||
|
oauthStep: "client_registration",
|
||||||
|
oauthMetadata: mockOAuthMetadata as unknown as OAuthMetadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText("Continue"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRegisterClient).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Should save and advance with the DCR client info
|
||||||
|
expect(updateAuthState).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
oauthClientInfo: expect.objectContaining({
|
||||||
|
client_id: "test_client_id",
|
||||||
|
}),
|
||||||
|
oauthStep: "authorization_redirect",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the dynamically registered client info was persisted
|
||||||
|
expect(sessionStorage.setItem).toHaveBeenCalledWith(
|
||||||
|
`[${defaultProps.serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`,
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("OAuth State Persistence", () => {
|
describe("OAuth State Persistence", () => {
|
||||||
it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => {
|
it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => {
|
||||||
const updateAuthState = jest.fn();
|
const updateAuthState = jest.fn();
|
||||||
|
|
@ -509,7 +687,9 @@ describe("AuthDebugger", () => {
|
||||||
mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue(
|
mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue(
|
||||||
mockResourceMetadata,
|
mockResourceMetadata,
|
||||||
);
|
);
|
||||||
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
|
mockDiscoverAuthorizationServerMetadata.mockResolvedValue(
|
||||||
|
mockOAuthMetadata,
|
||||||
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
renderAuthDebugger({
|
renderAuthDebugger({
|
||||||
|
|
@ -563,7 +743,9 @@ describe("AuthDebugger", () => {
|
||||||
// Mock failed metadata discovery
|
// Mock failed metadata discovery
|
||||||
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(mockError);
|
mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(mockError);
|
||||||
// But OAuth metadata should still work with the original URL
|
// But OAuth metadata should still work with the original URL
|
||||||
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
|
mockDiscoverAuthorizationServerMetadata.mockResolvedValue(
|
||||||
|
mockOAuthMetadata,
|
||||||
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
renderAuthDebugger({
|
renderAuthDebugger({
|
||||||
|
|
@ -603,7 +785,7 @@ describe("AuthDebugger", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify that regular OAuth metadata discovery was still called
|
// Verify that regular OAuth metadata discovery was still called
|
||||||
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
|
expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
||||||
new URL("https://example.com/"),
|
new URL("https://example.com/"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
155
client/src/lib/__tests__/auth.test.ts
Normal file
155
client/src/lib/__tests__/auth.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { discoverScopes } from "../auth";
|
||||||
|
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
|
|
||||||
|
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||||
|
discoverAuthorizationServerMetadata: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockDiscoverAuth =
|
||||||
|
discoverAuthorizationServerMetadata as jest.MockedFunction<
|
||||||
|
typeof discoverAuthorizationServerMetadata
|
||||||
|
>;
|
||||||
|
|
||||||
|
const baseMetadata = {
|
||||||
|
issuer: "https://test.com",
|
||||||
|
authorization_endpoint: "https://test.com/authorize",
|
||||||
|
token_endpoint: "https://test.com/token",
|
||||||
|
response_types_supported: ["code"],
|
||||||
|
grant_types_supported: ["authorization_code"],
|
||||||
|
scopes_supported: ["read", "write"],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("discoverScopes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
name: "returns joined scopes from OAuth metadata",
|
||||||
|
mockResolves: baseMetadata,
|
||||||
|
serverUrl: "https://example.com",
|
||||||
|
expected: "read write",
|
||||||
|
expectedCallUrl: "https://example.com/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prefers resource metadata over OAuth metadata",
|
||||||
|
mockResolves: baseMetadata,
|
||||||
|
serverUrl: "https://example.com",
|
||||||
|
resourceMetadata: {
|
||||||
|
resource: "https://example.com",
|
||||||
|
scopes_supported: ["admin", "full"],
|
||||||
|
},
|
||||||
|
expected: "admin full",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "falls back to OAuth when resource has empty scopes",
|
||||||
|
mockResolves: baseMetadata,
|
||||||
|
serverUrl: "https://example.com",
|
||||||
|
resourceMetadata: {
|
||||||
|
resource: "https://example.com",
|
||||||
|
scopes_supported: [],
|
||||||
|
},
|
||||||
|
expected: "read write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normalizes URL with port and path",
|
||||||
|
mockResolves: baseMetadata,
|
||||||
|
serverUrl: "https://example.com:8080/some/path",
|
||||||
|
expected: "read write",
|
||||||
|
expectedCallUrl: "https://example.com:8080/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normalizes URL with trailing slash",
|
||||||
|
mockResolves: baseMetadata,
|
||||||
|
serverUrl: "https://example.com/",
|
||||||
|
expected: "read write",
|
||||||
|
expectedCallUrl: "https://example.com/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "handles single scope",
|
||||||
|
mockResolves: { ...baseMetadata, scopes_supported: ["admin"] },
|
||||||
|
serverUrl: "https://example.com",
|
||||||
|
expected: "admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prefers resource metadata even with fewer scopes",
|
||||||
|
mockResolves: {
|
||||||
|
...baseMetadata,
|
||||||
|
scopes_supported: ["read", "write", "admin", "full"],
|
||||||
|
},
|
||||||
|
serverUrl: "https://example.com",
|
||||||
|
resourceMetadata: {
|
||||||
|
resource: "https://example.com",
|
||||||
|
scopes_supported: ["read"],
|
||||||
|
},
|
||||||
|
expected: "read",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const undefinedCases = [
|
||||||
|
{
|
||||||
|
name: "returns undefined when OAuth discovery fails",
|
||||||
|
mockRejects: new Error("Discovery failed"),
|
||||||
|
serverUrl: "https://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "returns undefined when OAuth has no scopes",
|
||||||
|
mockResolves: { ...baseMetadata, scopes_supported: [] },
|
||||||
|
serverUrl: "https://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "returns undefined when scopes_supported missing",
|
||||||
|
mockResolves: (() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { scopes_supported, ...rest } = baseMetadata;
|
||||||
|
return rest;
|
||||||
|
})(),
|
||||||
|
serverUrl: "https://example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "returns undefined with resource metadata but OAuth fails",
|
||||||
|
mockRejects: new Error("No OAuth metadata"),
|
||||||
|
serverUrl: "https://example.com",
|
||||||
|
resourceMetadata: {
|
||||||
|
resource: "https://example.com",
|
||||||
|
scopes_supported: ["read", "write"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(testCases)(
|
||||||
|
"$name",
|
||||||
|
async ({
|
||||||
|
mockResolves,
|
||||||
|
serverUrl,
|
||||||
|
resourceMetadata,
|
||||||
|
expected,
|
||||||
|
expectedCallUrl,
|
||||||
|
}) => {
|
||||||
|
mockDiscoverAuth.mockResolvedValue(mockResolves);
|
||||||
|
|
||||||
|
const result = await discoverScopes(serverUrl, resourceMetadata);
|
||||||
|
|
||||||
|
expect(result).toBe(expected);
|
||||||
|
if (expectedCallUrl) {
|
||||||
|
expect(mockDiscoverAuth).toHaveBeenCalledWith(new URL(expectedCallUrl));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(undefinedCases)(
|
||||||
|
"$name",
|
||||||
|
async ({ mockResolves, mockRejects, serverUrl, resourceMetadata }) => {
|
||||||
|
if (mockRejects) {
|
||||||
|
mockDiscoverAuth.mockRejectedValue(mockRejects);
|
||||||
|
} else {
|
||||||
|
mockDiscoverAuth.mockResolvedValue(mockResolves);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await discoverScopes(serverUrl, resourceMetadata);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -6,8 +6,45 @@ import {
|
||||||
OAuthTokensSchema,
|
OAuthTokensSchema,
|
||||||
OAuthClientMetadata,
|
OAuthClientMetadata,
|
||||||
OAuthMetadata,
|
OAuthMetadata,
|
||||||
|
OAuthProtectedResourceMetadata,
|
||||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
|
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
|
||||||
|
import { generateOAuthState } from "@/utils/oauthUtils";
|
||||||
|
import { validateRedirectUrl } from "@/utils/urlValidation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovers OAuth scopes from server metadata, with preference for resource metadata scopes
|
||||||
|
* @param serverUrl - The MCP server URL
|
||||||
|
* @param resourceMetadata - Optional resource metadata containing preferred scopes
|
||||||
|
* @returns Promise resolving to space-separated scope string or undefined
|
||||||
|
*/
|
||||||
|
export const discoverScopes = async (
|
||||||
|
serverUrl: string,
|
||||||
|
resourceMetadata?: OAuthProtectedResourceMetadata,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
try {
|
||||||
|
const metadata = await discoverAuthorizationServerMetadata(
|
||||||
|
new URL("/", serverUrl),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prefer resource metadata scopes, but fall back to OAuth metadata if empty
|
||||||
|
const resourceScopes = resourceMetadata?.scopes_supported;
|
||||||
|
const oauthScopes = metadata?.scopes_supported;
|
||||||
|
|
||||||
|
const scopesSupported =
|
||||||
|
resourceScopes && resourceScopes.length > 0
|
||||||
|
? resourceScopes
|
||||||
|
: oauthScopes;
|
||||||
|
|
||||||
|
return scopesSupported && scopesSupported.length > 0
|
||||||
|
? scopesSupported.join(" ")
|
||||||
|
: undefined;
|
||||||
|
} catch (error) {
|
||||||
|
console.debug("OAuth scope discovery failed:", error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getClientInformationFromSessionStorage = async ({
|
export const getClientInformationFromSessionStorage = async ({
|
||||||
serverUrl,
|
serverUrl,
|
||||||
|
|
@ -66,10 +103,15 @@ export const clearClientInformationFromSessionStorage = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||||
constructor(protected serverUrl: string) {
|
constructor(
|
||||||
|
protected serverUrl: string,
|
||||||
|
scope?: string,
|
||||||
|
) {
|
||||||
|
this.scope = scope;
|
||||||
// Save the server URL to session storage
|
// Save the server URL to session storage
|
||||||
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
|
||||||
}
|
}
|
||||||
|
scope: string | undefined;
|
||||||
|
|
||||||
get redirectUrl() {
|
get redirectUrl() {
|
||||||
return window.location.origin + "/oauth/callback";
|
return window.location.origin + "/oauth/callback";
|
||||||
|
|
@ -83,9 +125,14 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||||
response_types: ["code"],
|
response_types: ["code"],
|
||||||
client_name: "MCP Inspector",
|
client_name: "MCP Inspector",
|
||||||
client_uri: "https://github.com/modelcontextprotocol/inspector",
|
client_uri: "https://github.com/modelcontextprotocol/inspector",
|
||||||
|
scope: this.scope ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state(): string | Promise<string> {
|
||||||
|
return generateOAuthState();
|
||||||
|
}
|
||||||
|
|
||||||
async clientInformation() {
|
async clientInformation() {
|
||||||
// Try to get the preregistered client information from session storage first
|
// Try to get the preregistered client information from session storage first
|
||||||
const preregisteredClientInformation =
|
const preregisteredClientInformation =
|
||||||
|
|
@ -105,10 +152,17 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
saveClientInformation(clientInformation: OAuthClientInformation) {
|
saveClientInformation(clientInformation: OAuthClientInformation) {
|
||||||
|
// Remove client_secret before storing (not needed after initial OAuth flow)
|
||||||
|
const safeInfo = Object.fromEntries(
|
||||||
|
Object.entries(clientInformation).filter(
|
||||||
|
([key]) => key !== "client_secret",
|
||||||
|
),
|
||||||
|
) as OAuthClientInformation;
|
||||||
|
|
||||||
// Save the dynamically registered client information to session storage
|
// Save the dynamically registered client information to session storage
|
||||||
saveClientInformationToSessionStorage({
|
saveClientInformationToSessionStorage({
|
||||||
serverUrl: this.serverUrl,
|
serverUrl: this.serverUrl,
|
||||||
clientInformation,
|
clientInformation: safeInfo,
|
||||||
isPreregistered: false,
|
isPreregistered: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +183,8 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToAuthorization(authorizationUrl: URL) {
|
redirectToAuthorization(authorizationUrl: URL) {
|
||||||
|
// Validate the URL using the shared utility
|
||||||
|
validateRedirectUrl(authorizationUrl.href);
|
||||||
window.location.href = authorizationUrl.href;
|
window.location.href = authorizationUrl.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,16 @@ import { useConnection } from "../useConnection";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
|
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
|
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
|
||||||
import { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js";
|
import {
|
||||||
|
SSEClientTransportOptions,
|
||||||
|
SseError,
|
||||||
|
} from "@modelcontextprotocol/sdk/client/sse.js";
|
||||||
import {
|
import {
|
||||||
ElicitResult,
|
ElicitResult,
|
||||||
ElicitRequest,
|
ElicitRequest,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
|
import { discoverScopes } from "../../auth";
|
||||||
|
|
||||||
// Mock fetch
|
// Mock fetch
|
||||||
global.fetch = jest.fn().mockResolvedValue({
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
|
|
@ -53,14 +58,27 @@ jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||||
Client: jest.fn().mockImplementation(() => mockClient),
|
Client: jest.fn().mockImplementation(() => mockClient),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
|
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => {
|
||||||
SSEClientTransport: jest.fn((url, options) => {
|
// Minimal mock class that supports instanceof checks
|
||||||
mockSSETransport.url = url;
|
class SseError extends Error {
|
||||||
mockSSETransport.options = options;
|
code: number;
|
||||||
return mockSSETransport;
|
event: ErrorEvent;
|
||||||
}),
|
constructor(code: number, message: string, event: ErrorEvent) {
|
||||||
SseError: jest.fn(),
|
super(message);
|
||||||
}));
|
this.code = code;
|
||||||
|
this.event = event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
SSEClientTransport: jest.fn((url, options) => {
|
||||||
|
mockSSETransport.url = url;
|
||||||
|
mockSSETransport.options = options;
|
||||||
|
return mockSSETransport;
|
||||||
|
}),
|
||||||
|
SseError,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||||
StreamableHTTPClientTransport: jest.fn((url, options) => {
|
StreamableHTTPClientTransport: jest.fn((url, options) => {
|
||||||
|
|
@ -85,11 +103,18 @@ jest.mock("@/lib/hooks/useToast", () => ({
|
||||||
jest.mock("../../auth", () => ({
|
jest.mock("../../auth", () => ({
|
||||||
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
|
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
|
||||||
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
|
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
|
||||||
|
redirectUrl: "http://localhost:3000/oauth/callback",
|
||||||
})),
|
})),
|
||||||
clearClientInformationFromSessionStorage: jest.fn(),
|
clearClientInformationFromSessionStorage: jest.fn(),
|
||||||
saveClientInformationToSessionStorage: jest.fn(),
|
saveClientInformationToSessionStorage: jest.fn(),
|
||||||
|
discoverScopes: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockAuth = auth as jest.MockedFunction<typeof auth>;
|
||||||
|
const mockDiscoverScopes = discoverScopes as jest.MockedFunction<
|
||||||
|
typeof discoverScopes
|
||||||
|
>;
|
||||||
|
|
||||||
describe("useConnection", () => {
|
describe("useConnection", () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
transportType: "sse" as const,
|
transportType: "sse" as const,
|
||||||
|
|
@ -714,4 +739,142 @@ describe("useConnection", () => {
|
||||||
).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token");
|
).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("OAuth Error Handling with Scope Discovery", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockAuth.mockResolvedValue("AUTHORIZED");
|
||||||
|
mockDiscoverScopes.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const setup401Error = () => {
|
||||||
|
const mockErrorEvent = new ErrorEvent("error", {
|
||||||
|
message: "Mock error event",
|
||||||
|
});
|
||||||
|
mockClient.connect.mockRejectedValueOnce(
|
||||||
|
new SseError(401, "Unauthorized", mockErrorEvent),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const attemptConnection = async (props = defaultProps) => {
|
||||||
|
const { result } = renderHook(() => useConnection(props));
|
||||||
|
await act(async () => {
|
||||||
|
try {
|
||||||
|
await result.current.connect();
|
||||||
|
} catch {
|
||||||
|
// Expected error from auth handling
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const testCases = [
|
||||||
|
[
|
||||||
|
"discovers and includes scopes in auth call",
|
||||||
|
{
|
||||||
|
discoveredScope: "read write admin",
|
||||||
|
oauthScope: undefined,
|
||||||
|
expectScopeCall: true,
|
||||||
|
expectedAuthScope: "read write admin",
|
||||||
|
authResult: "AUTHORIZED",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"handles scope discovery failure gracefully",
|
||||||
|
{
|
||||||
|
discoveredScope: undefined,
|
||||||
|
oauthScope: undefined,
|
||||||
|
expectScopeCall: true,
|
||||||
|
expectedAuthScope: undefined,
|
||||||
|
authResult: "AUTHORIZED",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"uses manual oauthScope override instead of discovered scopes",
|
||||||
|
{
|
||||||
|
discoveredScope: "discovered:scope",
|
||||||
|
oauthScope: "manual:scope",
|
||||||
|
expectScopeCall: false,
|
||||||
|
expectedAuthScope: "manual:scope",
|
||||||
|
authResult: "AUTHORIZED",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"triggers scope discovery when oauthScope is whitespace",
|
||||||
|
{
|
||||||
|
discoveredScope: "discovered:scope",
|
||||||
|
oauthScope: " ",
|
||||||
|
expectScopeCall: true,
|
||||||
|
expectedAuthScope: "discovered:scope",
|
||||||
|
authResult: "AUTHORIZED",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"handles auth failure after scope discovery",
|
||||||
|
{
|
||||||
|
discoveredScope: "read write",
|
||||||
|
oauthScope: undefined,
|
||||||
|
expectScopeCall: true,
|
||||||
|
expectedAuthScope: "read write",
|
||||||
|
authResult: "UNAUTHORIZED",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test.each(testCases)(
|
||||||
|
"should %s",
|
||||||
|
async (
|
||||||
|
_,
|
||||||
|
{
|
||||||
|
discoveredScope,
|
||||||
|
oauthScope,
|
||||||
|
expectScopeCall,
|
||||||
|
expectedAuthScope,
|
||||||
|
authResult = "AUTHORIZED",
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
mockDiscoverScopes.mockResolvedValue(discoveredScope);
|
||||||
|
mockAuth.mockResolvedValue(authResult as never);
|
||||||
|
setup401Error();
|
||||||
|
|
||||||
|
const props =
|
||||||
|
oauthScope !== undefined
|
||||||
|
? { ...defaultProps, oauthScope }
|
||||||
|
: defaultProps;
|
||||||
|
await attemptConnection(props);
|
||||||
|
|
||||||
|
if (expectScopeCall) {
|
||||||
|
expect(mockDiscoverScopes).toHaveBeenCalledWith(
|
||||||
|
defaultProps.sseUrl,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
expect(mockDiscoverScopes).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), {
|
||||||
|
serverUrl: defaultProps.sseUrl,
|
||||||
|
scope: expectedAuthScope,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("should handle slow scope discovery gracefully", async () => {
|
||||||
|
mockDiscoverScopes.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => setTimeout(() => resolve(undefined), 100)),
|
||||||
|
);
|
||||||
|
|
||||||
|
setup401Error();
|
||||||
|
await attemptConnection();
|
||||||
|
|
||||||
|
expect(mockDiscoverScopes).toHaveBeenCalledWith(
|
||||||
|
defaultProps.sseUrl,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(mockAuth).toHaveBeenCalledWith(expect.any(Object), {
|
||||||
|
serverUrl: defaultProps.sseUrl,
|
||||||
|
scope: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,16 @@ import { useEffect, useState } from "react";
|
||||||
import { useToast } from "@/lib/hooks/useToast";
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ConnectionStatus } from "../constants";
|
import { ConnectionStatus } from "../constants";
|
||||||
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
import { Notification } from "../notificationTypes";
|
||||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
import {
|
||||||
|
auth,
|
||||||
|
discoverOAuthProtectedResourceMetadata,
|
||||||
|
} from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
import {
|
import {
|
||||||
clearClientInformationFromSessionStorage,
|
clearClientInformationFromSessionStorage,
|
||||||
InspectorOAuthClientProvider,
|
InspectorOAuthClientProvider,
|
||||||
saveClientInformationToSessionStorage,
|
saveClientInformationToSessionStorage,
|
||||||
|
discoverScopes,
|
||||||
} from "../auth";
|
} from "../auth";
|
||||||
import packageJson from "../../../package.json";
|
import packageJson from "../../../package.json";
|
||||||
import {
|
import {
|
||||||
|
|
@ -88,7 +92,6 @@ export function useConnection({
|
||||||
oauthScope,
|
oauthScope,
|
||||||
config,
|
config,
|
||||||
onNotification,
|
onNotification,
|
||||||
onStdErrNotification,
|
|
||||||
onPendingRequest,
|
onPendingRequest,
|
||||||
onElicitationRequest,
|
onElicitationRequest,
|
||||||
getRoots,
|
getRoots,
|
||||||
|
|
@ -315,11 +318,27 @@ export function useConnection({
|
||||||
|
|
||||||
const handleAuthError = async (error: unknown) => {
|
const handleAuthError = async (error: unknown) => {
|
||||||
if (is401Error(error)) {
|
if (is401Error(error)) {
|
||||||
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
let scope = oauthScope?.trim();
|
||||||
|
if (!scope) {
|
||||||
|
// Only discover resource metadata when we need to discover scopes
|
||||||
|
let resourceMetadata;
|
||||||
|
try {
|
||||||
|
resourceMetadata = await discoverOAuthProtectedResourceMetadata(
|
||||||
|
new URL("/", sseUrl),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Resource metadata is optional, continue without it
|
||||||
|
}
|
||||||
|
scope = await discoverScopes(sseUrl, resourceMetadata);
|
||||||
|
}
|
||||||
|
const serverAuthProvider = new InspectorOAuthClientProvider(
|
||||||
|
sseUrl,
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await auth(serverAuthProvider, {
|
const result = await auth(serverAuthProvider, {
|
||||||
serverUrl: sseUrl,
|
serverUrl: sseUrl,
|
||||||
scope: oauthScope,
|
scope,
|
||||||
});
|
});
|
||||||
return result === "AUTHORIZED";
|
return result === "AUTHORIZED";
|
||||||
}
|
}
|
||||||
|
|
@ -351,6 +370,7 @@ export function useConnection({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastRequest = "";
|
||||||
try {
|
try {
|
||||||
// Inject auth manually instead of using SSEClientTransport, because we're
|
// Inject auth manually instead of using SSEClientTransport, because we're
|
||||||
// proxying through the inspector server first.
|
// proxying through the inspector server first.
|
||||||
|
|
@ -484,13 +504,6 @@ export function useConnection({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onStdErrNotification) {
|
|
||||||
client.setNotificationHandler(
|
|
||||||
StdErrNotificationSchema,
|
|
||||||
onStdErrNotification,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let capabilities;
|
let capabilities;
|
||||||
try {
|
try {
|
||||||
const transport =
|
const transport =
|
||||||
|
|
@ -564,7 +577,9 @@ export function useConnection({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (capabilities?.logging && defaultLoggingLevel) {
|
if (capabilities?.logging && defaultLoggingLevel) {
|
||||||
|
lastRequest = "logging/setLevel";
|
||||||
await client.setLoggingLevel(defaultLoggingLevel);
|
await client.setLoggingLevel(defaultLoggingLevel);
|
||||||
|
lastRequest = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onElicitationRequest) {
|
if (onElicitationRequest) {
|
||||||
|
|
@ -578,6 +593,17 @@ export function useConnection({
|
||||||
setMcpClient(client);
|
setMcpClient(client);
|
||||||
setConnectionStatus("connected");
|
setConnectionStatus("connected");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
lastRequest === "logging/setLevel" &&
|
||||||
|
e instanceof McpError &&
|
||||||
|
e.code === ErrorCode.MethodNotFound
|
||||||
|
) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: `Server declares logging capability but doesn't implement method: "${lastRequest}"`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setConnectionStatus("error");
|
setConnectionStatus("error");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,8 @@ import {
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const StdErrNotificationSchema = BaseNotificationSchema.extend({
|
|
||||||
method: z.literal("notifications/stderr"),
|
|
||||||
params: z.object({
|
|
||||||
content: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const NotificationSchema = ClientNotificationSchema.or(
|
export const NotificationSchema = ClientNotificationSchema.or(
|
||||||
StdErrNotificationSchema,
|
ServerNotificationSchema,
|
||||||
)
|
).or(BaseNotificationSchema);
|
||||||
.or(ServerNotificationSchema)
|
|
||||||
.or(BaseNotificationSchema);
|
|
||||||
|
|
||||||
export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>;
|
|
||||||
export type Notification = z.infer<typeof NotificationSchema>;
|
export type Notification = z.infer<typeof NotificationSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { OAuthStep, AuthDebuggerState } from "./auth-types";
|
import { OAuthStep, AuthDebuggerState } from "./auth-types";
|
||||||
import { DebugInspectorOAuthClientProvider } from "./auth";
|
import { DebugInspectorOAuthClientProvider, discoverScopes } from "./auth";
|
||||||
import {
|
import {
|
||||||
discoverOAuthMetadata,
|
discoverAuthorizationServerMetadata,
|
||||||
registerClient,
|
registerClient,
|
||||||
startAuthorization,
|
startAuthorization,
|
||||||
exchangeAuthorization,
|
exchangeAuthorization,
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
OAuthMetadataSchema,
|
OAuthMetadataSchema,
|
||||||
OAuthProtectedResourceMetadata,
|
OAuthProtectedResourceMetadata,
|
||||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
import { generateOAuthState } from "@/utils/oauthUtils";
|
||||||
|
|
||||||
export interface StateMachineContext {
|
export interface StateMachineContext {
|
||||||
state: AuthDebuggerState;
|
state: AuthDebuggerState;
|
||||||
|
|
@ -56,7 +57,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
||||||
resourceMetadata ?? undefined,
|
resourceMetadata ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const metadata = await discoverOAuthMetadata(authServerUrl);
|
const metadata = await discoverAuthorizationServerMetadata(authServerUrl);
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
throw new Error("Failed to discover OAuth metadata");
|
throw new Error("Failed to discover OAuth metadata");
|
||||||
}
|
}
|
||||||
|
|
@ -88,12 +89,16 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
||||||
clientMetadata.scope = scopesSupported.join(" ");
|
clientMetadata.scope = scopesSupported.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullInformation = await registerClient(context.serverUrl, {
|
// Try Static client first, with DCR as fallback
|
||||||
metadata,
|
let fullInformation = await context.provider.clientInformation();
|
||||||
clientMetadata,
|
if (!fullInformation) {
|
||||||
});
|
fullInformation = await registerClient(context.serverUrl, {
|
||||||
|
metadata,
|
||||||
|
clientMetadata,
|
||||||
|
});
|
||||||
|
context.provider.saveClientInformation(fullInformation);
|
||||||
|
}
|
||||||
|
|
||||||
context.provider.saveClientInformation(fullInformation);
|
|
||||||
context.updateState({
|
context.updateState({
|
||||||
oauthClientInfo: fullInformation,
|
oauthClientInfo: fullInformation,
|
||||||
oauthStep: "authorization_redirect",
|
oauthStep: "authorization_redirect",
|
||||||
|
|
@ -108,17 +113,10 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
||||||
const metadata = context.state.oauthMetadata!;
|
const metadata = context.state.oauthMetadata!;
|
||||||
const clientInformation = context.state.oauthClientInfo!;
|
const clientInformation = context.state.oauthClientInfo!;
|
||||||
|
|
||||||
let scope: string | undefined = undefined;
|
const scope = await discoverScopes(
|
||||||
if (metadata.scopes_supported) {
|
context.serverUrl,
|
||||||
scope = metadata.scopes_supported.join(" ");
|
context.state.resourceMetadata ?? undefined,
|
||||||
}
|
);
|
||||||
|
|
||||||
// Generate a random state
|
|
||||||
const array = new Uint8Array(32);
|
|
||||||
crypto.getRandomValues(array);
|
|
||||||
const state = Array.from(array, (byte) =>
|
|
||||||
byte.toString(16).padStart(2, "0"),
|
|
||||||
).join("");
|
|
||||||
|
|
||||||
const { authorizationUrl, codeVerifier } = await startAuthorization(
|
const { authorizationUrl, codeVerifier } = await startAuthorization(
|
||||||
context.serverUrl,
|
context.serverUrl,
|
||||||
|
|
@ -127,7 +125,7 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
||||||
clientInformation,
|
clientInformation,
|
||||||
redirectUrl: context.provider.redirectUrl,
|
redirectUrl: context.provider.redirectUrl,
|
||||||
scope,
|
scope,
|
||||||
state: state,
|
state: generateOAuthState(),
|
||||||
resource: context.state.resource ?? undefined,
|
resource: context.state.resource ?? undefined,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -179,7 +177,11 @@ export const oauthTransitions: Record<OAuthStep, StateTransition> = {
|
||||||
authorizationCode: context.state.authorizationCode,
|
authorizationCode: context.state.authorizationCode,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
redirectUri: context.provider.redirectUrl,
|
redirectUri: context.provider.redirectUrl,
|
||||||
resource: context.state.resource ?? undefined,
|
resource: context.state.resource
|
||||||
|
? context.state.resource instanceof URL
|
||||||
|
? context.state.resource
|
||||||
|
: new URL(context.state.resource)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
context.provider.saveTokens(tokens);
|
context.provider.saveTokens(tokens);
|
||||||
|
|
|
||||||
86
client/src/utils/__tests__/oauthUtils.test.ts
Normal file
86
client/src/utils/__tests__/oauthUtils.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import {
|
||||||
|
generateOAuthErrorDescription,
|
||||||
|
parseOAuthCallbackParams,
|
||||||
|
generateOAuthState,
|
||||||
|
} from "@/utils/oauthUtils.ts";
|
||||||
|
|
||||||
|
describe("parseOAuthCallbackParams", () => {
|
||||||
|
it("Returns successful: true and code when present", () => {
|
||||||
|
expect(parseOAuthCallbackParams("?code=fake-code")).toEqual({
|
||||||
|
successful: true,
|
||||||
|
code: "fake-code",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("Returns successful: false and error when error is present", () => {
|
||||||
|
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
|
||||||
|
successful: false,
|
||||||
|
error: "access_denied",
|
||||||
|
error_description: null,
|
||||||
|
error_uri: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("Returns optional error metadata fields when present", () => {
|
||||||
|
const search =
|
||||||
|
"?error=access_denied&" +
|
||||||
|
"error_description=User%20Denied%20Request&" +
|
||||||
|
"error_uri=https%3A%2F%2Fexample.com%2Ferror-docs";
|
||||||
|
expect(parseOAuthCallbackParams(search)).toEqual({
|
||||||
|
successful: false,
|
||||||
|
error: "access_denied",
|
||||||
|
error_description: "User Denied Request",
|
||||||
|
error_uri: "https://example.com/error-docs",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("Returns error when nothing present", () => {
|
||||||
|
expect(parseOAuthCallbackParams("?")).toEqual({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "Missing code or error in response",
|
||||||
|
error_uri: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateOAuthErrorDescription", () => {
|
||||||
|
it("When only error is present", () => {
|
||||||
|
expect(
|
||||||
|
generateOAuthErrorDescription({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: null,
|
||||||
|
error_uri: null,
|
||||||
|
}),
|
||||||
|
).toBe("Error: invalid_request.");
|
||||||
|
});
|
||||||
|
it("When error description is present", () => {
|
||||||
|
expect(
|
||||||
|
generateOAuthErrorDescription({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "The request could not be completed as dialed",
|
||||||
|
error_uri: null,
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
"Error: invalid_request.\nDetails: The request could not be completed as dialed.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("When all fields present", () => {
|
||||||
|
expect(
|
||||||
|
generateOAuthErrorDescription({
|
||||||
|
successful: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "The request could not be completed as dialed",
|
||||||
|
error_uri: "https://example.com/error-docs",
|
||||||
|
}),
|
||||||
|
).toEqual(
|
||||||
|
"Error: invalid_request.\nDetails: The request could not be completed as dialed.\nMore info: https://example.com/error-docs.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateOAuthState", () => {
|
||||||
|
it("Returns a string", () => {
|
||||||
|
expect(generateOAuthState()).toBeDefined();
|
||||||
|
expect(generateOAuthState()).toHaveLength(64);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
135
client/src/utils/__tests__/urlValidation.test.ts
Normal file
135
client/src/utils/__tests__/urlValidation.test.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { validateRedirectUrl } from "../urlValidation";
|
||||||
|
|
||||||
|
describe("validateRedirectUrl", () => {
|
||||||
|
describe("valid URLs", () => {
|
||||||
|
it("should allow HTTP URLs", () => {
|
||||||
|
expect(() => validateRedirectUrl("http://example.com")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow HTTPS URLs", () => {
|
||||||
|
expect(() => validateRedirectUrl("https://example.com")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow URLs with ports", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("https://example.com:8080"),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow URLs with paths", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("https://example.com/path/to/auth"),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow URLs with query parameters", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("https://example.com?param=value"),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invalid URLs - XSS vectors", () => {
|
||||||
|
it("should block javascript: protocol", () => {
|
||||||
|
expect(() => validateRedirectUrl("javascript:alert('XSS')")).toThrow(
|
||||||
|
"Authorization URL must be HTTP or HTTPS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block javascript: with encoded characters", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("javascript:alert%28%27XSS%27%29"),
|
||||||
|
).toThrow("Authorization URL must be HTTP or HTTPS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block data: protocol", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("data:text/html,<script>alert('XSS')</script>"),
|
||||||
|
).toThrow("Authorization URL must be HTTP or HTTPS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block vbscript: protocol", () => {
|
||||||
|
expect(() => validateRedirectUrl("vbscript:msgbox")).toThrow(
|
||||||
|
"Authorization URL must be HTTP or HTTPS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block file: protocol", () => {
|
||||||
|
expect(() => validateRedirectUrl("file:///etc/passwd")).toThrow(
|
||||||
|
"Authorization URL must be HTTP or HTTPS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block about: protocol", () => {
|
||||||
|
expect(() => validateRedirectUrl("about:blank")).toThrow(
|
||||||
|
"Authorization URL must be HTTP or HTTPS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block custom protocols", () => {
|
||||||
|
expect(() => validateRedirectUrl("custom://example")).toThrow(
|
||||||
|
"Authorization URL must be HTTP or HTTPS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle malformed URLs", () => {
|
||||||
|
expect(() => validateRedirectUrl("not a url")).toThrow(
|
||||||
|
"Invalid URL: not a url",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty string", () => {
|
||||||
|
expect(() => validateRedirectUrl("")).toThrow("Invalid URL: ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle URLs with unicode characters", () => {
|
||||||
|
expect(() => validateRedirectUrl("https://例え.jp")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle URLs with case variations", () => {
|
||||||
|
expect(() => validateRedirectUrl("HTTPS://EXAMPLE.COM")).not.toThrow();
|
||||||
|
expect(() => validateRedirectUrl("HtTpS://example.com")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle protocol-relative URLs as invalid", () => {
|
||||||
|
expect(() => validateRedirectUrl("//example.com")).toThrow(
|
||||||
|
"Invalid URL: //example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle URLs with authentication", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("https://user:pass@example.com"),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("security considerations", () => {
|
||||||
|
it("should not be fooled by whitespace", () => {
|
||||||
|
expect(() => validateRedirectUrl(" javascript:alert('XSS')")).toThrow();
|
||||||
|
expect(() => validateRedirectUrl("javascript:alert('XSS') ")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null bytes", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("java\x00script:alert('XSS')"),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tab characters", () => {
|
||||||
|
expect(() => validateRedirectUrl("java\tscript:alert('XSS')")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle newlines", () => {
|
||||||
|
expect(() => validateRedirectUrl("java\nscript:alert('XSS')")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mixed case protocols", () => {
|
||||||
|
expect(() => validateRedirectUrl("JaVaScRiPt:alert('XSS')")).toThrow(
|
||||||
|
"Authorization URL must be HTTP or HTTPS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue