Skip to content

Commit 4c095fc

Browse files
committed
Cancel background traffic before login/register so session cookie isn't clobbered
When handle_user_login invalidates the previous anonymous session and a concurrent request using the old cookie is still in flight, the server creates a *new* anonymous session for it and responds with a fresh `Set-Cookie: galaxysession=<anon>`. If that response lands between the login POST and the full-page navigation, the browser navigates with the anonymous cookie and the new page loads logged out. Under the TEMP SSE flag this happens often enough to trip `wait_for_logged_in` in selenium. Fix: synchronously close all long-lived connections (SSE, polling watchers) and rotate a shared axios AbortController before sending the login/register POST. With no in-flight anonymous-cookie request, the server can't emit the clobbering Set-Cookie, and the authenticated cookie survives until navigation.
1 parent 57f1aba commit 4c095fc

8 files changed

Lines changed: 201 additions & 27 deletions

File tree

client/src/api/pendingRequests.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Shared ``AbortController`` for axios requests so we can cancel everything
3+
* still in flight right before a login/register navigation.
4+
*
5+
* Why this exists: when the server processes a request that carries the old
6+
* anonymous ``galaxysession`` cookie *after* ``handle_user_login`` has marked
7+
* that session ``is_valid=False``, it creates a fresh anonymous session and
8+
* responds with ``Set-Cookie: galaxysession=<new>``. Delivered into the cookie
9+
* jar after the login response but before the new page loads, it overwrites
10+
* the just-issued authenticated cookie — so the new page loads anonymous and
11+
* ``wait_for_logged_in`` times out in selenium. Aborting the TCP connection
12+
* before the response headers are parsed prevents that ``Set-Cookie`` from
13+
* ever applying.
14+
*/
15+
import axios, { type InternalAxiosRequestConfig } from "axios";
16+
17+
let activeController = new AbortController();
18+
19+
/** Explicit opt-out header that the login/register POST itself sets. */
20+
export const SKIP_PENDING_REQUESTS_HEADER = "x-galaxy-skip-pending-abort";
21+
22+
/**
23+
* Install a request interceptor that attaches the shared signal to every
24+
* outgoing axios request that didn't set one itself. Call once at app boot.
25+
*/
26+
export function installPendingRequestsInterceptor() {
27+
axios.interceptors.request.use((config: InternalAxiosRequestConfig) => {
28+
if (config.signal !== undefined) {
29+
return config;
30+
}
31+
if (config.headers?.[SKIP_PENDING_REQUESTS_HEADER]) {
32+
delete config.headers[SKIP_PENDING_REQUESTS_HEADER];
33+
return config;
34+
}
35+
config.signal = activeController.signal;
36+
return config;
37+
});
38+
}
39+
40+
/**
41+
* Abort every axios request that is using the shared signal and install a
42+
* fresh controller so subsequent requests can still go out.
43+
*/
44+
export function cancelPendingAxiosRequests() {
45+
activeController.abort();
46+
activeController = new AbortController();
47+
}

client/src/components/Login/LoginForm.vue

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
import { computed, ref } from "vue";
1616
import { useRouter } from "vue-router/composables";
1717
18+
import { SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests";
19+
import { discardActiveConnectionsBeforeAuthNavigation } from "@/composables/useAuthNavigation";
1820
import localize from "@/utils/localization";
1921
import { withPrefix } from "@/utils/redirect";
2022
import { errorMessageAsString } from "@/utils/simple-error";
@@ -86,13 +88,22 @@ async function submitLogin() {
8688
redirect = props.redirect ?? null;
8789
}
8890
91+
// Close SSE, stop polling, and abort in-flight axios before sending the
92+
// login POST — otherwise a late anonymous-cookie response can overwrite
93+
// the authenticated cookie we're about to receive.
94+
discardActiveConnectionsBeforeAuthNavigation();
95+
8996
try {
90-
const response = await axios.post(withPrefix("/user/login"), {
91-
login: login.value,
92-
password: password.value,
93-
redirect: redirect,
94-
session_csrf_token: props.sessionCsrfToken,
95-
});
97+
const response = await axios.post(
98+
withPrefix("/user/login"),
99+
{
100+
login: login.value,
101+
password: password.value,
102+
redirect: redirect,
103+
session_csrf_token: props.sessionCsrfToken,
104+
},
105+
{ headers: { [SKIP_PENDING_REQUESTS_HEADER]: "1" } },
106+
);
96107
97108
if (response.data.message && response.data.status) {
98109
alert(response.data.message);

client/src/components/Register/RegisterForm.vue

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import {
1414
} from "bootstrap-vue";
1515
import { computed, type Ref, ref } from "vue";
1616
17+
import { SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests";
1718
import { getOIDCIdpsWithRegistration, type OIDCConfig } from "@/components/User/ExternalIdentities/ExternalIDHelper";
1819
import { Toast } from "@/composables/toast";
20+
import { discardActiveConnectionsBeforeAuthNavigation } from "@/composables/useAuthNavigation";
1921
import localize from "@/utils/localization";
2022
import { withPrefix } from "@/utils/redirect";
2123
import { errorMessageAsString } from "@/utils/simple-error";
@@ -72,15 +74,24 @@ const registerColumnDisplay = computed(() => Boolean(props.termsUrl));
7274
async function submit() {
7375
disableCreate.value = true;
7476
77+
// Close SSE, stop polling, and abort in-flight axios before sending the
78+
// register POST — otherwise a late anonymous-cookie response can overwrite
79+
// the authenticated cookie we're about to receive.
80+
discardActiveConnectionsBeforeAuthNavigation();
81+
7582
try {
76-
const response = await axios.post(withPrefix("/user/create"), {
77-
email: email.value,
78-
username: username.value,
79-
password: password.value,
80-
confirm: confirm.value,
81-
subscribe: subscribe.value,
82-
session_csrf_token: props.sessionCsrfToken,
83-
});
83+
const response = await axios.post(
84+
withPrefix("/user/create"),
85+
{
86+
email: email.value,
87+
username: username.value,
88+
password: password.value,
89+
confirm: confirm.value,
90+
subscribe: subscribe.value,
91+
session_csrf_token: props.sessionCsrfToken,
92+
},
93+
{ headers: { [SKIP_PENDING_REQUESTS_HEADER]: "1" } },
94+
);
8495
8596
if (response.data.message && response.data.status) {
8697
Toast.info(response.data.message);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { cancelPendingAxiosRequests } from "@/api/pendingRequests";
2+
import { useEntryPointStore } from "@/stores/entryPointStore";
3+
import { useHistoryStore } from "@/stores/historyStore";
4+
import { useNotificationsStore } from "@/stores/notificationsStore";
5+
6+
/**
7+
* Tear down every long-lived connection and cancel every in-flight axios
8+
* request so that nothing issued under the old anonymous ``galaxysession``
9+
* cookie can land on the server after ``handle_user_login`` has invalidated
10+
* it. See ``client/src/api/pendingRequests.ts`` for the race this guards
11+
* against.
12+
*
13+
* Call this synchronously as the first step of a login or registration
14+
* submit, before the authenticating POST goes out. The shared abort
15+
* controller is rotated, so the login/register POST (issued right after)
16+
* will use a fresh signal and is not affected.
17+
*/
18+
export function discardActiveConnectionsBeforeAuthNavigation() {
19+
// Order: close SSE streams first (synchronous TCP close), then stop the
20+
// polling watchers so they can't kick off new fetches, then abort any
21+
// axios requests still in flight.
22+
useHistoryStore().stopWatchingHistory();
23+
useEntryPointStore().stopWatchingEntryPoints();
24+
useNotificationsStore().stopWatchingNotifications();
25+
cancelPendingAxiosRequests();
26+
}

client/src/entry/analysis/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { createPinia, PiniaVuePlugin } from "pinia";
33
import Vue from "vue";
44

5+
import { installPendingRequestsInterceptor } from "@/api/pendingRequests";
56
import { initGalaxyInstance } from "@/app";
67
import { initSentry } from "@/app/addons/sentry";
78
import { initWebhooks } from "@/app/addons/webhooks";
@@ -13,6 +14,12 @@ import App from "./App.vue";
1314
Vue.use(PiniaVuePlugin);
1415
const pinia = createPinia();
1516

17+
// Attach the shared AbortController signal to every outgoing axios request
18+
// so we can cancel in-flight anonymous-cookie requests before login/register
19+
// navigates — otherwise their late ``Set-Cookie: galaxysession=<anon>`` can
20+
// clobber the authenticated cookie.
21+
installPendingRequestsInterceptor();
22+
1623
window.addEventListener("load", async () => {
1724
// Create Galaxy object
1825
const Galaxy = await initGalaxyInstance();

client/src/stores/entryPointStore.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,13 @@ export const useEntryPointStore = defineStore("entryPointStore", () => {
4343
function handleEntryPointSSEEvent(_event: MessageEvent) {
4444
fetchEntryPoints().catch((err) => console.error("Error refreshing entry points from SSE push:", err));
4545
}
46-
const { connect: sseConnect, connected: sseConnected } = useSSE(handleEntryPointSSEEvent, ["entry_point_update"]);
46+
const {
47+
connect: sseConnect,
48+
disconnect: sseDisconnect,
49+
connected: sseConnected,
50+
} = useSSE(handleEntryPointSSEEvent, ["entry_point_update"]);
51+
let stopPolling: (() => void) | null = null;
52+
let stopConnectedWatcher: (() => void) | null = null;
4753

4854
let watchingInitialized = false;
4955

@@ -65,18 +71,19 @@ export const useEntryPointStore = defineStore("entryPointStore", () => {
6571
// navigated away and missed events" window.
6672
fetchEntryPoints().catch((err) => console.warn("Initial entry-point load failed", err));
6773
sseConnect();
68-
watch(sseConnected, (isConnected, wasConnected) => {
74+
stopConnectedWatcher = watch(sseConnected, (isConnected, wasConnected) => {
6975
if (isConnected && !wasConnected) {
7076
fetchEntryPoints().catch((err) =>
7177
console.error("Error refreshing entry points on SSE reconnect:", err),
7278
);
7379
}
7480
});
7581
} else {
76-
const { startWatchingResource } = useResourceWatcher(fetchEntryPoints, {
82+
const { startWatchingResource, stopWatchingResource } = useResourceWatcher(fetchEntryPoints, {
7783
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
7884
enableBackgroundPolling: false,
7985
});
86+
stopPolling = stopWatchingResource;
8087
startWatchingResource();
8188
}
8289
};
@@ -139,6 +146,22 @@ export const useEntryPointStore = defineStore("entryPointStore", () => {
139146
}
140147
}
141148

149+
// Closes the SSE stream and stops the polling watcher; paired with login
150+
// /register flows so background traffic doesn't outlive the navigation
151+
// and clobber the freshly authenticated session cookie.
152+
function stopWatchingEntryPoints() {
153+
sseDisconnect();
154+
if (stopPolling) {
155+
stopPolling();
156+
stopPolling = null;
157+
}
158+
if (stopConnectedWatcher) {
159+
stopConnectedWatcher();
160+
stopConnectedWatcher = null;
161+
}
162+
watchingInitialized = false;
163+
}
164+
142165
return {
143166
entryPoints,
144167
entryPointsForJob,
@@ -147,5 +170,6 @@ export const useEntryPointStore = defineStore("entryPointStore", () => {
147170
updateEntryPoints,
148171
removeEntryPoint,
149172
startWatchingEntryPoints,
173+
stopWatchingEntryPoints,
150174
};
151175
});

client/src/stores/historyStore.ts

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,12 @@ export const useHistoryStore = defineStore("historyStore", () => {
397397
// SSE-driven history updates: when we receive a history_update event,
398398
// immediately trigger a refresh of the current history
399399
const SSE_HISTORY_EVENT_TYPES = ["history_update"] as const;
400-
const { connect: sseHistoryConnect } = useSSE(handleHistorySSEEvent, SSE_HISTORY_EVENT_TYPES);
400+
const { connect: sseHistoryConnect, disconnect: sseHistoryDisconnect } = useSSE(
401+
handleHistorySSEEvent,
402+
SSE_HISTORY_EVENT_TYPES,
403+
);
404+
let stopHistoryPolling: (() => void) | null = null;
405+
let stopIsWatchingWatcher: (() => void) | null = null;
401406

402407
function handleHistorySSEEvent(event: MessageEvent) {
403408
try {
@@ -448,11 +453,17 @@ export const useHistoryStore = defineStore("historyStore", () => {
448453
// The resource watcher fires its handler once immediately and
449454
// then re-schedules on the polling interval, which covers the
450455
// initial load as well as ongoing updates.
451-
const { startWatchingResource, isWatchingResource } = useResourceWatcher(watchHistory, {
452-
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
453-
longPollingInterval: INACTIVE_POLLING_INTERVAL,
456+
const { startWatchingResource, stopWatchingResource, isWatchingResource } = useResourceWatcher(
457+
watchHistory,
458+
{
459+
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
460+
longPollingInterval: INACTIVE_POLLING_INTERVAL,
461+
},
462+
);
463+
stopHistoryPolling = stopWatchingResource;
464+
stopIsWatchingWatcher = watch(isWatchingResource, (v) => (isWatchingHistory.value = v), {
465+
immediate: true,
454466
});
455-
watch(isWatchingResource, (v) => (isWatchingHistory.value = v), { immediate: true });
456467
startWatchingResource();
457468
}
458469
};
@@ -569,6 +580,23 @@ export const useHistoryStore = defineStore("historyStore", () => {
569580
return contentStats;
570581
}
571582

583+
// Closes SSE and stops polling so the watcher can't emit a trailing
584+
// anonymous-cookie request that would overwrite the authenticated
585+
// ``galaxysession`` cookie set by the login/register response.
586+
function stopWatchingHistory() {
587+
sseHistoryDisconnect();
588+
if (stopHistoryPolling) {
589+
stopHistoryPolling();
590+
stopHistoryPolling = null;
591+
}
592+
if (stopIsWatchingWatcher) {
593+
stopIsWatchingWatcher();
594+
stopIsWatchingWatcher = null;
595+
}
596+
isWatchingHistory.value = false;
597+
watchingInitialized = false;
598+
}
599+
572600
return {
573601
histories,
574602
changingCurrentHistory,
@@ -598,6 +626,7 @@ export const useHistoryStore = defineStore("historyStore", () => {
598626
restoreHistories,
599627
handleTotalCountChange,
600628
startWatchingHistory: startWatchingHistoryWithSSE,
629+
stopWatchingHistory,
601630
isWatchingHistory,
602631
loadCurrentHistory,
603632
loadCurrentHistoryId,

client/src/stores/notificationsStore.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export const useNotificationsStore = defineStore("notificationsStore", () => {
2727

2828
// --- SSE setup (listen only for notification event types) ---
2929
const NOTIFICATION_EVENT_TYPES = ["notification_update", "broadcast_update", "notification_status"] as const;
30-
const { connect: sseConnect } = useSSE(handleSSEEvent, NOTIFICATION_EVENT_TYPES);
30+
const { connect: sseConnect, disconnect: sseDisconnect } = useSSE(handleSSEEvent, NOTIFICATION_EVENT_TYPES);
31+
let stopPolling: (() => void) | null = null;
3132

3233
function handleSSEEvent(event: MessageEvent) {
3334
try {
@@ -132,10 +133,14 @@ export const useNotificationsStore = defineStore("notificationsStore", () => {
132133
if (configStore.config?.enable_notification_system) {
133134
sseConnect();
134135
} else {
135-
const { startWatchingResource: startPolling } = useResourceWatcher(getNotificationStatus, {
136-
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
137-
longPollingInterval: INACTIVE_POLLING_INTERVAL,
138-
});
136+
const { startWatchingResource: startPolling, stopWatchingResource } = useResourceWatcher(
137+
getNotificationStatus,
138+
{
139+
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
140+
longPollingInterval: INACTIVE_POLLING_INTERVAL,
141+
},
142+
);
143+
stopPolling = stopWatchingResource;
139144
startPolling();
140145
}
141146
};
@@ -201,6 +206,19 @@ export const useNotificationsStore = defineStore("notificationsStore", () => {
201206
totalUnreadCount.value = notifications.value.filter((n) => !n.seen_time).length;
202207
}
203208

209+
// Closes the SSE stream and stops the polling watcher so nothing running
210+
// in the background can outlive a full-page navigation (login/register).
211+
// A late-arriving response from an anonymous-cookie request would otherwise
212+
// overwrite the just-issued authenticated ``galaxysession`` cookie.
213+
function stopWatchingNotifications() {
214+
sseDisconnect();
215+
if (stopPolling) {
216+
stopPolling();
217+
stopPolling = null;
218+
}
219+
watchingInitialized = false;
220+
}
221+
204222
return {
205223
notifications,
206224
totalUnreadCount,
@@ -209,5 +227,6 @@ export const useNotificationsStore = defineStore("notificationsStore", () => {
209227
updateNotification,
210228
updateBatchNotification,
211229
startWatchingNotifications,
230+
stopWatchingNotifications,
212231
};
213232
});

0 commit comments

Comments
 (0)