diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 6252c1161..8700a87ed 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -1,12 +1,24 @@ -import { useCallback, useMemo, useEffect } from "react"; +import { useCallback, useMemo, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; -import { DebugInspectorOAuthClientProvider } from "../lib/auth"; +import { + DebugInspectorOAuthClientProvider, + clearClientInformationFromSessionStorage, +} from "../lib/auth"; import { AlertCircle } from "lucide-react"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types"; import { OAuthFlowProgress } from "./OAuthFlowProgress"; import { OAuthStateMachine } from "../lib/oauth-state-machine"; import { SESSION_KEYS } from "../lib/constants"; import { validateRedirectUrl } from "@/utils/urlValidation"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Checkbox } from "@/components/ui/checkbox"; export interface AuthDebuggerProps { serverUrl: string; @@ -61,6 +73,9 @@ const AuthDebugger = ({ authState, updateAuthState, }: AuthDebuggerProps) => { + const [clearDialogOpen, setClearDialogOpen] = useState(false); + const [clearDcrClient, setClearDcrClient] = useState(false); + // Check for existing tokens on mount useEffect(() => { if (serverUrl && !authState.oauthTokens) { @@ -94,8 +109,15 @@ const AuthDebugger = ({ return; } + // Clear cached DCR client info so Step 2 performs a fresh registration + clearClientInformationFromSessionStorage({ + serverUrl, + isPreregistered: false, + }); + updateAuthState({ oauthStep: "metadata_discovery", + oauthClientInfo: null, authorizationUrl: null, statusMessage: null, latestError: null, @@ -216,26 +238,42 @@ const AuthDebugger = ({ } }, [serverUrl, updateAuthState, authState]); - const handleClearOAuth = useCallback(() => { - if (serverUrl) { - const serverAuthProvider = new DebugInspectorOAuthClientProvider( - serverUrl, - ); - serverAuthProvider.clear(); - updateAuthState({ - ...EMPTY_DEBUGGER_STATE, - statusMessage: { - type: "success", - message: "OAuth tokens cleared successfully", - }, - }); + const handleClearOAuth = useCallback( + (alsoClearDcrClient: boolean) => { + if (serverUrl) { + const serverAuthProvider = new DebugInspectorOAuthClientProvider( + serverUrl, + ); + serverAuthProvider.clear(); - // Clear success message after 3 seconds - setTimeout(() => { - updateAuthState({ statusMessage: null }); - }, 3000); - } - }, [serverUrl, updateAuthState]); + if (alsoClearDcrClient) { + clearClientInformationFromSessionStorage({ + serverUrl, + isPreregistered: false, + }); + } + + updateAuthState({ + ...EMPTY_DEBUGGER_STATE, + oauthClientInfo: alsoClearDcrClient + ? null + : EMPTY_DEBUGGER_STATE.oauthClientInfo, + statusMessage: { + type: "success", + message: alsoClearDcrClient + ? "OAuth tokens and client registration cleared successfully" + : "OAuth tokens cleared successfully", + }, + }); + + // Clear success message after 3 seconds + setTimeout(() => { + updateAuthState({ statusMessage: null }); + }, 3000); + } + }, + [serverUrl, updateAuthState], + ); return (
@@ -295,7 +333,13 @@ const AuthDebugger = ({ : "Quick OAuth Flow"} -
@@ -316,6 +360,50 @@ const AuthDebugger = ({ + + + + + Clear OAuth State + + This will clear OAuth tokens, code verifier, and server metadata + for this session. + + +
+ setClearDcrClient(checked === true)} + /> +
+ +

+ The server will need to re-register this client on the next + OAuth flow. +

+
+
+ + + + +
+
); }; diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index fec876778..f0da82e31 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -64,7 +64,6 @@ jest.mock("@/lib/auth", () => ({ 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", ); @@ -103,10 +102,14 @@ jest.mock("@/lib/auth", () => ({ saveServerMetadata: jest.fn(), getServerMetadata: jest.fn(), })), - discoverScopes: jest.fn().mockResolvedValue("read write" as never), + clearClientInformationFromSessionStorage: jest.fn(), + discoverScopes: jest.fn().mockResolvedValue("read write"), })); -import { discoverScopes } from "../../lib/auth"; +import { + discoverScopes, + clearClientInformationFromSessionStorage, +} from "../../lib/auth"; // Type the mocked functions properly const mockDiscoverAuthorizationServerMetadata = @@ -130,6 +133,10 @@ const mockDiscoverOAuthProtectedResourceMetadata = const mockDiscoverScopes = discoverScopes as jest.MockedFunction< typeof discoverScopes >; +const mockClearClientInformation = + clearClientInformationFromSessionStorage as jest.MockedFunction< + typeof clearClientInformationFromSessionStorage + >; const sessionStorageMock = { getItem: jest.fn(), @@ -234,6 +241,31 @@ describe("AuthDebugger", () => { expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument(); }); + it("should clear cached DCR client info when starting Guided OAuth Flow", async () => { + const updateAuthState = jest.fn(); + await act(async () => { + renderAuthDebugger({ updateAuthState }); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Guided OAuth Flow")); + }); + + // Should clear DCR client info from sessionStorage + expect(mockClearClientInformation).toHaveBeenCalledWith({ + serverUrl: "https://example.com/mcp", + isPreregistered: false, + }); + + // Should also clear oauthClientInfo in React state + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + oauthClientInfo: null, + oauthStep: "metadata_discovery", + }), + ); + }); + it("should show error when OAuth flow is started without sseUrl", async () => { const updateAuthState = jest.fn(); await act(async () => { @@ -355,9 +387,65 @@ describe("AuthDebugger", () => { }); describe("OAuth State Management", () => { - it("should clear OAuth state when Clear button is clicked", async () => { + it("should open confirmation dialog when Clear OAuth State is clicked", async () => { + await act(async () => { + renderAuthDebugger({ + authState: { + ...defaultAuthState, + oauthTokens: mockOAuthTokens, + }, + }); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Clear OAuth State")); + }); + + // Dialog should be visible + expect( + screen.getByText( + "This will clear OAuth tokens, code verifier, and server metadata for this session.", + ), + ).toBeInTheDocument(); + expect( + screen.getByText("Also clear registered client information"), + ).toBeInTheDocument(); + }); + + it("should close dialog without clearing when Cancel is clicked", async () => { + const updateAuthState = jest.fn(); + await act(async () => { + renderAuthDebugger({ + authState: { + ...defaultAuthState, + oauthTokens: mockOAuthTokens, + }, + updateAuthState, + }); + }); + + // Open dialog + await act(async () => { + fireEvent.click(screen.getByText("Clear OAuth State")); + }); + + // Click Cancel + await act(async () => { + fireEvent.click(screen.getByText("Cancel")); + }); + + // updateAuthState should not have been called with a clear action + expect(updateAuthState).not.toHaveBeenCalledWith( + expect.objectContaining({ + statusMessage: expect.objectContaining({ + message: expect.stringContaining("cleared"), + }), + }), + ); + }); + + it("should clear OAuth state without DCR client when Clear is clicked without checkbox", 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); @@ -375,10 +463,16 @@ describe("AuthDebugger", () => { }); }); + // Open dialog await act(async () => { fireEvent.click(screen.getByText("Clear OAuth State")); }); + // Click Clear without checking the checkbox + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + }); + expect(updateAuthState).toHaveBeenCalledWith({ authServerUrl: null, authorizationUrl: null, @@ -401,6 +495,60 @@ describe("AuthDebugger", () => { // Verify session storage was cleared expect(sessionStorageMock.removeItem).toHaveBeenCalled(); + + // Should NOT clear DCR client info + expect(mockClearClientInformation).not.toHaveBeenCalled(); + }); + + it("should clear OAuth state and DCR client when Clear is clicked with checkbox checked", async () => { + const updateAuthState = jest.fn(); + 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, + }); + }); + + // Open dialog + await act(async () => { + fireEvent.click(screen.getByText("Clear OAuth State")); + }); + + // Check the checkbox + await act(async () => { + fireEvent.click(screen.getByRole("checkbox")); + }); + + // Click Clear + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + }); + + expect(updateAuthState).toHaveBeenCalledWith( + expect.objectContaining({ + statusMessage: { + type: "success", + message: + "OAuth tokens and client registration cleared successfully", + }, + }), + ); + + // Should also clear DCR client info + expect(mockClearClientInformation).toHaveBeenCalledWith({ + serverUrl: "https://example.com/mcp", + isPreregistered: false, + }); }); }); @@ -710,6 +858,7 @@ describe("AuthDebugger", () => { // Verify that the flow started with metadata discovery expect(updateAuthState).toHaveBeenCalledWith({ oauthStep: "metadata_discovery", + oauthClientInfo: null, authorizationUrl: null, statusMessage: null, latestError: null, diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 879936104..699b33862 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -245,10 +245,6 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { } clear() { - clearClientInformationFromSessionStorage({ - serverUrl: this.serverUrl, - isPreregistered: false, - }); sessionStorage.removeItem( getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl), );