Skip to content

Commit 9fd36f8

Browse files
committed
fix: studio re-login when session expires
Fixed Issue ArcadeData#4082
1 parent 76f212a commit 9fd36f8

3 files changed

Lines changed: 150 additions & 12 deletions

File tree

server/src/test/java/com/arcadedb/server/HTTPAuthSessionIT.java

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323
import org.junit.jupiter.api.Test;
2424

2525
import java.io.IOException;
26+
import java.io.InputStream;
2627
import java.net.HttpURLConnection;
2728
import java.net.URL;
29+
import java.nio.charset.StandardCharsets;
2830
import java.util.Base64;
2931
import java.util.HashMap;
3032
import java.util.logging.Level;
@@ -394,4 +396,87 @@ void tokenWithTransaction() throws Exception {
394396
}
395397
});
396398
}
399+
400+
/**
401+
* Regression test for <a href="https://github.com/ArcadeData/arcadedb/issues/4082">issue #4082</a>:
402+
* after a server restart, all in-memory auth sessions are lost. A client that still holds the
403+
* previous Bearer token must receive HTTP 401 with a JSON body that Studio can react to by
404+
* resetting the session and showing the login page.
405+
* <p>
406+
* Here we simulate the restart by explicitly removing the session from the manager. Studio's
407+
* global ajaxError handler relies on this 401 to detect the dropped session and prompt the user
408+
* to log in again, instead of leaving the UI stuck on stale data.
409+
*/
410+
@Test
411+
void invalidTokenAfterServerRestartReturns401() throws Exception {
412+
testEachServer((serverIndex) -> {
413+
// 1. LOGIN
414+
HttpURLConnection connection = (HttpURLConnection) new URL(
415+
"http://127.0.0.1:248" + serverIndex + "/api/v1/login").openConnection();
416+
connection.setRequestMethod("POST");
417+
connection.setRequestProperty("Authorization",
418+
"Basic " + Base64.getEncoder().encodeToString(("root:" + BaseGraphServerTest.DEFAULT_PASSWORD_FOR_TESTS).getBytes()));
419+
connection.connect();
420+
421+
final String authToken;
422+
try {
423+
final String response = readResponse(connection);
424+
assertThat(connection.getResponseCode()).isEqualTo(200);
425+
authToken = new JSONObject(response).getString("token");
426+
} finally {
427+
connection.disconnect();
428+
}
429+
430+
// 2. SANITY CHECK: Token is valid right after login
431+
connection = (HttpURLConnection) new URL(
432+
"http://127.0.0.1:248" + serverIndex + "/api/v1/databases").openConnection();
433+
connection.setRequestMethod("GET");
434+
connection.setRequestProperty("Authorization", "Bearer " + authToken);
435+
connection.connect();
436+
try {
437+
assertThat(connection.getResponseCode()).isEqualTo(200);
438+
} finally {
439+
connection.disconnect();
440+
}
441+
442+
// 3. SIMULATE SERVER RESTART: drop the session from the in-memory session manager
443+
// (a real restart would reinitialize the manager and lose all sessions the same way).
444+
getServer(serverIndex).getHttpServer().getAuthSessionManager().removeSession(authToken);
445+
446+
// 4. CLIENT STILL USES THE OLD TOKEN -> server must return 401 with a parseable JSON body
447+
connection = (HttpURLConnection) new URL(
448+
"http://127.0.0.1:248" + serverIndex + "/api/v1/databases").openConnection();
449+
connection.setRequestMethod("GET");
450+
connection.setRequestProperty("Authorization", "Bearer " + authToken);
451+
connection.connect();
452+
453+
try {
454+
assertThat(connection.getResponseCode()).isEqualTo(401);
455+
// Read from the error stream because the status is 4xx
456+
final InputStream err = connection.getErrorStream();
457+
assertThat(err).isNotNull();
458+
final String body = new String(err.readAllBytes(), StandardCharsets.UTF_8);
459+
assertThat(body).isNotBlank();
460+
// Body must be JSON with an "error" key so Studio's globalNotifyError can parse it
461+
final JSONObject json = new JSONObject(body);
462+
assertThat(json.has("error")).isTrue();
463+
assertThat(json.getString("error")).contains("Invalid or expired authentication token");
464+
} finally {
465+
connection.disconnect();
466+
}
467+
468+
// 5. SAME TOKEN AGAINST OTHER ENDPOINTS: must consistently return 401 so the Studio's
469+
// global handler kicks in regardless of which call was the one to hit the dead session.
470+
connection = (HttpURLConnection) new URL(
471+
"http://127.0.0.1:248" + serverIndex + "/api/v1/query/" + DATABASE_NAME + "/sql/select%201").openConnection();
472+
connection.setRequestMethod("GET");
473+
connection.setRequestProperty("Authorization", "Bearer " + authToken);
474+
connection.connect();
475+
try {
476+
assertThat(connection.getResponseCode()).isEqualTo(401);
477+
} finally {
478+
connection.disconnect();
479+
}
480+
});
481+
}
397482
}

studio/src/main/resources/static/js/studio-ai.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,12 @@ function aiSendMessageStreaming(db, message) {
396396
signal: controller.signal
397397
})
398398
.then(function(response) {
399+
if (response.status === 401) {
400+
if (typeof handleSessionExpired === "function") handleSessionExpired();
401+
var err = new Error("Session expired");
402+
err.status = 401;
403+
throw err;
404+
}
399405
if (!response.ok) {
400406
return response.text().then(function(text) {
401407
var err = new Error("HTTP " + response.status);

studio/src/main/resources/static/js/studio-database.js

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,57 @@ function initSessionFromStorage() {
180180
return false;
181181
}
182182

183+
// Re-entry guard: when many AJAX calls are pending and the server has lost the
184+
// session (typically after a server restart), every one of them returns 401.
185+
// Without the flag we'd reset the UI multiple times and stack notifications.
186+
var sessionExpirationHandled = false;
187+
188+
// Returns true if the URL points at the local API and uses our session token,
189+
// so a 401 from there means our token is no longer valid (server restarted, session
190+
// removed or token expired). External URLs (datasets repo, etc.) are ignored.
191+
function isLocalApiUrl(url) {
192+
if (!url) return false;
193+
if (url.indexOf("://") !== -1) return false;
194+
return url.indexOf("api/v1/") === 0 || url.indexOf("/api/v1/") !== -1;
195+
}
196+
197+
function handleSessionExpired() {
198+
if (sessionExpirationHandled) return;
199+
if (!globalCredentials) return;
200+
201+
sessionExpirationHandled = true;
202+
203+
clearSession();
204+
globalCredentials = null;
205+
globalUsername = null;
206+
207+
$("#studioPanel").hide();
208+
$("#welcomePanel").hide();
209+
210+
if (typeof showLoginPopup === "function")
211+
showLoginPopup();
212+
213+
if (typeof globalNotify === "function")
214+
globalNotify("Session Expired", "Your session has expired. Please log in again.", "warning");
215+
216+
// Allow the next 401 (after the user logs back in) to be handled again.
217+
setTimeout(function () { sessionExpirationHandled = false; }, 2000);
218+
}
219+
220+
// Global jQuery handler: any 401 returned by an authenticated API call
221+
// (other than login/logout, which 401 for legitimate reasons) means the
222+
// session is no longer valid. Reset the UI back to the login screen.
223+
$(document).ajaxError(function (event, jqXHR, ajaxSettings) {
224+
if (!jqXHR || jqXHR.status !== 401) return;
225+
226+
var url = (ajaxSettings && ajaxSettings.url) || "";
227+
if (!isLocalApiUrl(url)) return;
228+
if (url.indexOf("api/v1/login") !== -1) return;
229+
if (url.indexOf("api/v1/logout") !== -1) return;
230+
231+
handleSessionExpired();
232+
});
233+
183234
function login() {
184235
var userName = $("#inputUserName").val().trim();
185236
if (userName.length == 0) {
@@ -438,19 +489,10 @@ function updateDatabases(callback, preferSelected) {
438489
responseText: jqXHR.responseText
439490
});
440491

441-
// Handle session expiration (401 = invalid/expired token)
442-
if (jqXHR.status === 401) {
443-
console.log("Session expired or invalid, clearing session");
444-
clearSession();
445-
globalCredentials = null;
446-
globalUsername = null;
447-
448-
// Silently redirect to login - no error popup needed
449-
$("#studioPanel").hide();
450-
$("#welcomePanel").show();
451-
showLoginPopup();
492+
// 401 is already handled by the global ajaxError hook (handleSessionExpired).
493+
// Just stop here so no error toast is shown for the expired-session case.
494+
if (jqXHR.status === 401)
452495
return;
453-
}
454496

455497
let errorMessage = "Failed to fetch databases";
456498
if (jqXHR.status === 403) {
@@ -622,6 +664,11 @@ function importWithSSE(command, onComplete, onError) {
622664
},
623665
body: JSON.stringify({ command: command })
624666
}).then(function(response) {
667+
if (response.status === 401) {
668+
handleSessionExpired();
669+
onError("Session expired");
670+
return;
671+
}
625672
if (!response.ok) {
626673
response.text().then(function(t) { onError(t); });
627674
return;

0 commit comments

Comments
 (0)