Skip to content

Commit a643df9

Browse files
committed
Clean up
1 parent 952fe04 commit a643df9

File tree

2 files changed

+136
-26
lines changed

2 files changed

+136
-26
lines changed

src/main/java/org/sonarsource/sonarqube/mcp/authentication/SessionTokenStore.java

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
import java.util.concurrent.ScheduledExecutorService;
2424
import java.util.concurrent.TimeUnit;
2525
import javax.annotation.Nullable;
26-
import org.sonarsource.sonarqube.mcp.log.McpLogger;
2726

2827
/**
2928
* Thread-safe store for session-to-token mappings with TTL-based expiration.
@@ -48,14 +47,16 @@ public class SessionTokenStore {
4847
private static final Duration SESSION_TTL = Duration.ofHours(1);
4948
private static final Duration CLEANUP_INTERVAL = Duration.ofMinutes(5);
5049
private static final SessionTokenStore INSTANCE = new SessionTokenStore();
51-
52-
/** Session entry with token and last access timestamp */
50+
51+
/**
52+
* Session entry with token and last access timestamp
53+
*/
5354
private record SessionEntry(String token, Instant lastAccess) {
5455
SessionEntry touch() {
5556
return new SessionEntry(token, Instant.now());
5657
}
5758
}
58-
59+
5960
// Maps sessionId → SessionEntry (token + last access time)
6061
private final ConcurrentHashMap<String, SessionEntry> sessionTokens = new ConcurrentHashMap<>();
6162
private final ScheduledExecutorService cleanupScheduler;
@@ -82,15 +83,9 @@ public static SessionTokenStore getInstance() {
8283
/**
8384
* Store the token for a NEW session only.
8485
* For existing sessions, validates that the token matches and refreshes the TTL.
85-
*
86-
* @param sessionId The MCP session ID
87-
* @param token The token from the request
88-
* @return true if token was stored/matches, false if token mismatch (hijacking attempt)
8986
*/
9087
public boolean setTokenIfValid(String sessionId, String token) {
9188
var newEntry = new SessionEntry(token, Instant.now());
92-
93-
// Use compute to atomically check-and-set or validate
9489
var result = new boolean[]{true};
9590
sessionTokens.compute(sessionId, (key, existing) -> {
9691
if (existing == null) {
@@ -106,16 +101,13 @@ public boolean setTokenIfValid(String sessionId, String token) {
106101
result[0] = false;
107102
return existing; // Keep existing entry unchanged
108103
});
109-
104+
110105
return result[0];
111106
}
112107

113108
/**
114109
* Get the token for a session, refreshing its TTL.
115110
* Called by tool execution to get the correct token.
116-
*
117-
* @param sessionId The MCP session ID
118-
* @return The token, or null if session not found or expired
119111
*/
120112
@Nullable
121113
public String getToken(String sessionId) {
@@ -127,7 +119,7 @@ public String getToken(String sessionId) {
127119
// Refresh TTL on access
128120
return existing.touch();
129121
});
130-
122+
131123
return entry != null ? entry.token() : null;
132124
}
133125

@@ -157,21 +149,13 @@ public void shutdown() {
157149
}
158150
clear();
159151
}
160-
152+
161153
private boolean isExpired(SessionEntry entry) {
162154
return Duration.between(entry.lastAccess(), Instant.now()).compareTo(SESSION_TTL) > 0;
163155
}
164-
156+
165157
private void cleanupExpiredSessions() {
166-
var expiredCount = 0;
167-
var iterator = sessionTokens.entrySet().iterator();
168-
while (iterator.hasNext()) {
169-
var entry = iterator.next();
170-
if (isExpired(entry.getValue())) {
171-
iterator.remove();
172-
expiredCount++;
173-
}
174-
}
158+
sessionTokens.entrySet().removeIf(entry -> isExpired(entry.getValue()));
175159
}
176160

177161
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* SonarQube MCP Server
3+
* Copyright (C) 2025 SonarSource
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonarsource.sonarqube.mcp.authentication;
18+
19+
import org.junit.jupiter.api.BeforeEach;
20+
import org.junit.jupiter.api.Test;
21+
22+
import static org.assertj.core.api.Assertions.assertThat;
23+
import static org.junit.jupiter.api.Assertions.assertThrows;
24+
25+
class SessionTokenStoreTest {
26+
27+
private SessionTokenStore store;
28+
29+
@BeforeEach
30+
void setUp() {
31+
store = SessionTokenStore.getInstance();
32+
store.clear();
33+
}
34+
35+
@Test
36+
void should_store_new_session_token() {
37+
var result = store.setTokenIfValid("session-1", "token-abc");
38+
39+
assertThat(result).isTrue();
40+
assertThat(store.getToken("session-1")).isEqualTo("token-abc");
41+
assertThat(store.size()).isEqualTo(1);
42+
}
43+
44+
@Test
45+
void should_accept_same_token_for_existing_session() {
46+
store.setTokenIfValid("session-1", "token-abc");
47+
48+
var result = store.setTokenIfValid("session-1", "token-abc");
49+
50+
assertThat(result).isTrue();
51+
assertThat(store.getToken("session-1")).isEqualTo("token-abc");
52+
}
53+
54+
@Test
55+
void should_reject_different_token_for_existing_session() {
56+
store.setTokenIfValid("session-1", "token-abc");
57+
58+
// Hijacking attempt: different token trying to use same session
59+
var result = store.setTokenIfValid("session-1", "token-HIJACKED");
60+
61+
assertThat(result).isFalse();
62+
// Original token should still be stored
63+
assertThat(store.getToken("session-1")).isEqualTo("token-abc");
64+
}
65+
66+
@Test
67+
void should_return_null_for_nonexistent_session() {
68+
assertThat(store.getToken("nonexistent-session")).isNull();
69+
}
70+
71+
@Test
72+
void should_throw_on_null_session_id() {
73+
assertThrows(NullPointerException.class, () -> store.getToken(null));
74+
}
75+
76+
@Test
77+
void should_store_multiple_sessions() {
78+
store.setTokenIfValid("session-1", "token-1");
79+
store.setTokenIfValid("session-2", "token-2");
80+
store.setTokenIfValid("session-3", "token-3");
81+
82+
assertThat(store.size()).isEqualTo(3);
83+
assertThat(store.getToken("session-1")).isEqualTo("token-1");
84+
assertThat(store.getToken("session-2")).isEqualTo("token-2");
85+
assertThat(store.getToken("session-3")).isEqualTo("token-3");
86+
}
87+
88+
@Test
89+
void should_clear_all_sessions() {
90+
store.setTokenIfValid("session-1", "token-1");
91+
store.setTokenIfValid("session-2", "token-2");
92+
93+
store.clear();
94+
95+
assertThat(store.size()).isZero();
96+
assertThat(store.getToken("session-1")).isNull();
97+
assertThat(store.getToken("session-2")).isNull();
98+
}
99+
100+
@Test
101+
void should_return_singleton_instance() {
102+
var instance1 = SessionTokenStore.getInstance();
103+
var instance2 = SessionTokenStore.getInstance();
104+
105+
assertThat(instance1).isSameAs(instance2);
106+
}
107+
108+
@Test
109+
void should_isolate_sessions_from_each_other() {
110+
// Two different users with different sessions and tokens
111+
store.setTokenIfValid("user-alice-session", "alice-token");
112+
store.setTokenIfValid("user-bob-session", "bob-token");
113+
114+
var aliceHijackAttempt = store.setTokenIfValid("user-bob-session", "alice-token");
115+
assertThat(aliceHijackAttempt).isFalse();
116+
117+
var bobHijackAttempt = store.setTokenIfValid("user-alice-session", "bob-token");
118+
assertThat(bobHijackAttempt).isFalse();
119+
120+
// Original tokens are preserved
121+
assertThat(store.getToken("user-alice-session")).isEqualTo("alice-token");
122+
assertThat(store.getToken("user-bob-session")).isEqualTo("bob-token");
123+
}
124+
125+
}
126+

0 commit comments

Comments
 (0)