11package com .dbaagent .security ;
22
3+ import com .dbaagent .repository .UserRepository ;
34import com .dbaagent .service .McpTokenService ;
45import jakarta .servlet .ServletException ;
56import org .junit .jupiter .api .AfterEach ;
2223
2324import static org .junit .jupiter .api .Assertions .assertEquals ;
2425import static org .junit .jupiter .api .Assertions .assertNotNull ;
26+ import static org .junit .jupiter .api .Assertions .assertNull ;
27+ import static org .junit .jupiter .api .Assertions .assertTrue ;
2528import static org .mockito .Mockito .when ;
2629
2730@ ExtendWith (MockitoExtension .class )
@@ -33,6 +36,9 @@ class McpTokenAuthenticationFilterTest {
3336 @ Mock
3437 private CustomUserDetailsService userDetailsService ;
3538
39+ @ Mock
40+ private UserRepository userRepository ;
41+
3642 @ InjectMocks
3743 private McpTokenAuthenticationFilter filter ;
3844
@@ -67,4 +73,100 @@ void authenticateUsesResolvedUsernameWithoutTouchingLazyEntity() throws ServletE
6773 assertNotNull (SecurityContextHolder .getContext ().getAuthentication ());
6874 assertEquals ("alice" , SecurityContextHolder .getContext ().getAuthentication ().getName ());
6975 }
76+
77+ /**
78+ * The cross-user token leak, at the layer that stops it.
79+ *
80+ * <p>The agent runtime keeps ONE MCP subprocess and the provisioner rotates
81+ * its credential on disk, so another user's Agent-tab open could leave
82+ * analyst's MCP process holding admin's token. Authenticating that token
83+ * would run analyst's tools as admin — reading connections analyst has no
84+ * grant for, and writing admin into the audit row. The request's own
85+ * DEEPSQL_MCP_USER_ID claim ("analyst") contradicts the token owner
86+ * ("admin"), which is the signal to refuse.
87+ */
88+ @ Test
89+ void rejectsTokenWhoseOwnerDiffersFromTheDeclaredMcpUser () throws ServletException , IOException {
90+ ReflectionTestUtils .setField (filter , "authEnabled" , true );
91+
92+ MockHttpServletRequest request = new MockHttpServletRequest ("GET" , "/api/connections" );
93+ request .addHeader ("Authorization" , "Bearer dsql_mcp_public.secret" );
94+ // This MCP process was provisioned for analyst...
95+ request .addHeader ("X-DeepSQL-Client-Agent" , "analyst" );
96+ request .setRemoteAddr ("127.0.0.1" );
97+
98+ MockHttpServletResponse response = new MockHttpServletResponse ();
99+ MockFilterChain chain = new MockFilterChain ();
100+
101+ when (mcpTokenService .looksLikeMcpToken ("dsql_mcp_public.secret" )).thenReturn (true );
102+ // ...but the token file was overwritten with admin's credential.
103+ when (mcpTokenService .authenticate ("dsql_mcp_public.secret" , "127.0.0.1" ))
104+ .thenReturn (Optional .of (new McpTokenService .AuthenticatedMcpToken (9L , "admin" )));
105+ when (userRepository .findByUsernameIgnoreCase ("analyst" ))
106+ .thenReturn (Optional .of (new com .dbaagent .model .User ()));
107+
108+ filter .doFilter (request , response , chain );
109+
110+ assertNull (SecurityContextHolder .getContext ().getAuthentication (),
111+ "a mismatched MCP credential must not authenticate anyone" );
112+ assertEquals (401 , response .getStatus ());
113+ assertTrue (response .getContentAsString ().contains ("mcp_identity_mismatch" ));
114+ assertNull (chain .getRequest (), "the request must not reach downstream handlers" );
115+ }
116+
117+ /** The normal agent case: the claim matches the token owner. */
118+ @ Test
119+ void allowsTokenWhenDeclaredMcpUserMatchesOwner () throws ServletException , IOException {
120+ ReflectionTestUtils .setField (filter , "authEnabled" , true );
121+
122+ MockHttpServletRequest request = new MockHttpServletRequest ("GET" , "/api/connections" );
123+ request .addHeader ("Authorization" , "Bearer dsql_mcp_public.secret" );
124+ request .addHeader ("X-DeepSQL-Client-Agent" , "analyst" );
125+ request .setRemoteAddr ("127.0.0.1" );
126+
127+ MockHttpServletResponse response = new MockHttpServletResponse ();
128+ MockFilterChain chain = new MockFilterChain ();
129+
130+ when (mcpTokenService .looksLikeMcpToken ("dsql_mcp_public.secret" )).thenReturn (true );
131+ when (mcpTokenService .authenticate ("dsql_mcp_public.secret" , "127.0.0.1" ))
132+ .thenReturn (Optional .of (new McpTokenService .AuthenticatedMcpToken (9L , "analyst" )));
133+ when (userDetailsService .loadUserByUsername ("analyst" ))
134+ .thenReturn (new User ("analyst" , "ignored" ,
135+ List .of (new SimpleGrantedAuthority ("ROLE_DEVELOPER" ))));
136+
137+ filter .doFilter (request , response , chain );
138+
139+ assertEquals ("analyst" , SecurityContextHolder .getContext ().getAuthentication ().getName ());
140+ }
141+
142+ /**
143+ * Editor/CLI installs put a *tool* name in this header ("cursor",
144+ * "claude-desktop", any --caller-agent value). Those tokens are not
145+ * agent-provisioned, so the claim must not be compared against a username —
146+ * otherwise every editor MCP install would 401.
147+ */
148+ @ Test
149+ void allowsEditorClientAgentThatIsNotADeepSqlUsername () throws ServletException , IOException {
150+ ReflectionTestUtils .setField (filter , "authEnabled" , true );
151+
152+ MockHttpServletRequest request = new MockHttpServletRequest ("GET" , "/api/connections" );
153+ request .addHeader ("Authorization" , "Bearer dsql_mcp_public.secret" );
154+ request .addHeader ("X-DeepSQL-Client-Agent" , "cursor" );
155+ request .setRemoteAddr ("127.0.0.1" );
156+
157+ MockHttpServletResponse response = new MockHttpServletResponse ();
158+ MockFilterChain chain = new MockFilterChain ();
159+
160+ when (mcpTokenService .looksLikeMcpToken ("dsql_mcp_public.secret" )).thenReturn (true );
161+ when (mcpTokenService .authenticate ("dsql_mcp_public.secret" , "127.0.0.1" ))
162+ .thenReturn (Optional .of (new McpTokenService .AuthenticatedMcpToken (11L , "bob" )));
163+ when (userRepository .findByUsernameIgnoreCase ("cursor" )).thenReturn (Optional .empty ());
164+ when (userDetailsService .loadUserByUsername ("bob" ))
165+ .thenReturn (new User ("bob" , "ignored" ,
166+ List .of (new SimpleGrantedAuthority ("ROLE_DEVELOPER" ))));
167+
168+ filter .doFilter (request , response , chain );
169+
170+ assertEquals ("bob" , SecurityContextHolder .getContext ().getAuthentication ().getName ());
171+ }
70172}
0 commit comments