Skip to content

Commit 5198d67

Browse files
authored
Merge pull request #22564 from mvdbeek/auth-cancel-26.0
[26.0] Cancel background traffic before login/register so session cookie isnt clobbered
2 parents 36f9e3f + 6a1a763 commit 5198d67

10 files changed

Lines changed: 175 additions & 29 deletions

File tree

client/src/api/client/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import createClient from "openapi-fetch";
22

3+
import { pendingRequestsMiddleware } from "@/api/client/pendingRequestsMiddleware";
34
import { createRateLimiterMiddleware } from "@/api/client/rateLimiter";
45
import type { GalaxyApiPaths } from "@/api/schema";
56
import { getAppRoot } from "@/onload/loadConfig";
@@ -12,6 +13,9 @@ function getBaseUrl() {
1213
function apiClientFactory() {
1314
const client = createClient<GalaxyApiPaths>({ baseUrl: getBaseUrl() });
1415

16+
// Registered first so aborted requests bypass the rate-limiter queue.
17+
client.use(pendingRequestsMiddleware);
18+
1519
// TODO: Adjust based on server limits (maybe this goes in Galaxy config?)
1620
client.use(
1721
createRateLimiterMiddleware({
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Middleware } from "openapi-fetch";
2+
3+
import { getPendingAbortSignal, SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests";
4+
5+
/**
6+
* Attaches the shared pending-requests signal to every ``GalaxyApi`` request.
7+
* The ``openapi-fetch`` client uses native ``fetch()`` so the axios
8+
* interceptor does not apply; without this middleware, login/register
9+
* navigations cannot cancel in-flight ``/api/...`` calls and a late
10+
* anonymous-cookie response can clobber the authenticated ``galaxysession``
11+
* cookie. See ``client/src/api/pendingRequests.ts`` for the race.
12+
*/
13+
export const pendingRequestsMiddleware: Middleware = {
14+
async onRequest({ request }) {
15+
if (request.headers.has(SKIP_PENDING_REQUESTS_HEADER)) {
16+
const headers = new Headers(request.headers);
17+
headers.delete(SKIP_PENDING_REQUESTS_HEADER);
18+
return new Request(request, { headers });
19+
}
20+
const shared = getPendingAbortSignal();
21+
const signal = typeof AbortSignal.any === "function" ? AbortSignal.any([request.signal, shared]) : shared;
22+
return new Request(request, { signal });
23+
},
24+
};

client/src/api/pendingRequests.ts

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

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+
// Stop polling and abort in-flight axios/GalaxyApi 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
@@ -15,8 +15,10 @@ import {
1515
} from "bootstrap-vue";
1616
import { computed, type Ref, ref } from "vue";
1717
18+
import { SKIP_PENDING_REQUESTS_HEADER } from "@/api/pendingRequests";
1819
import { getOIDCIdpsWithRegistration, type OIDCConfig } from "@/components/User/ExternalIdentities/ExternalIDHelper";
1920
import { Toast } from "@/composables/toast";
21+
import { discardActiveConnectionsBeforeAuthNavigation } from "@/composables/useAuthNavigation";
2022
import localize from "@/utils/localization";
2123
import { withPrefix } from "@/utils/redirect";
2224
import { errorMessageAsString } from "@/utils/simple-error";
@@ -70,15 +72,24 @@ const registerColumnDisplay = computed(() => Boolean(props.termsUrl));
7072
async function submit() {
7173
disableCreate.value = true;
7274
75+
// Stop polling and abort in-flight axios/GalaxyApi before sending the
76+
// register POST — otherwise a late anonymous-cookie response can overwrite
77+
// the authenticated cookie we're about to receive.
78+
discardActiveConnectionsBeforeAuthNavigation();
79+
7380
try {
74-
const response = await axios.post(withPrefix("/user/create"), {
75-
email: email.value,
76-
username: username.value,
77-
password: password.value,
78-
confirm: confirm.value,
79-
subscribe: subscribe.value,
80-
session_csrf_token: props.sessionCsrfToken,
81-
});
81+
const response = await axios.post(
82+
withPrefix("/user/create"),
83+
{
84+
email: email.value,
85+
username: username.value,
86+
password: password.value,
87+
confirm: confirm.value,
88+
subscribe: subscribe.value,
89+
session_csrf_token: props.sessionCsrfToken,
90+
},
91+
{ headers: { [SKIP_PENDING_REQUESTS_HEADER]: "1" } },
92+
);
8293
8394
if (response.data.message && response.data.status) {
8495
Toast.info(response.data.message);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { cancelPendingRequests } 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 request
8+
* (both axios and ``openapi-fetch``/GalaxyApi) so that nothing issued under
9+
* the old anonymous ``galaxysession`` cookie can land on the server after
10+
* ``handle_user_login`` has invalidated it. See
11+
* ``client/src/api/pendingRequests.ts`` for the race this guards 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+
// Stop polling watchers first so they can't kick off new fetches, then
20+
// abort any requests still in flight via the shared AbortController.
21+
useHistoryStore().stopWatchingHistory();
22+
useEntryPointStore().stopWatchingEntryPoints();
23+
useNotificationsStore().stopWatchingNotifications();
24+
cancelPendingRequests();
25+
}

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: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ interface EntryPoint {
2323
}
2424

2525
export const useEntryPointStore = defineStore("entryPointStore", () => {
26-
const { startWatchingResource: startWatchingEntryPoints } = useResourceWatcher(fetchEntryPoints, {
27-
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
28-
enableBackgroundPolling: false, // No need to poll in the background
29-
});
26+
const { startWatchingResource: startWatchingEntryPoints, stopWatchingResource: stopWatchingEntryPoints } =
27+
useResourceWatcher(fetchEntryPoints, {
28+
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
29+
enableBackgroundPolling: false, // No need to poll in the background
30+
});
3031

3132
const entryPoints = ref<EntryPoint[]>([]);
3233

@@ -92,5 +93,6 @@ export const useEntryPointStore = defineStore("entryPointStore", () => {
9293
updateEntryPoints,
9394
removeEntryPoint,
9495
startWatchingEntryPoints,
96+
stopWatchingEntryPoints,
9597
};
9698
});

client/src/stores/historyStore.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -379,13 +379,14 @@ export const useHistoryStore = defineStore("historyStore", () => {
379379
return watchHistorySuppliedApp(app);
380380
}
381381

382-
const { startWatchingResource: startWatchingHistory, isWatchingResource: isWatchingHistory } = useResourceWatcher(
383-
watchHistory,
384-
{
385-
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
386-
longPollingInterval: INACTIVE_POLLING_INTERVAL,
387-
},
388-
);
382+
const {
383+
startWatchingResource: startWatchingHistory,
384+
stopWatchingResource: stopWatchingHistory,
385+
isWatchingResource: isWatchingHistory,
386+
} = useResourceWatcher(watchHistory, {
387+
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
388+
longPollingInterval: INACTIVE_POLLING_INTERVAL,
389+
});
389390

390391
async function loadHistoryById(historyId: string): Promise<HistorySummaryExtended | undefined> {
391392
if (!isLoadingHistory.has(historyId)) {
@@ -508,6 +509,7 @@ export const useHistoryStore = defineStore("historyStore", () => {
508509
restoreHistories,
509510
handleTotalCountChange,
510511
startWatchingHistory,
512+
stopWatchingHistory,
511513
isWatchingHistory,
512514
loadCurrentHistory,
513515
loadCurrentHistoryId,

client/src/stores/notificationsStore.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ const ACTIVE_POLLING_INTERVAL = 30000; // 30 seconds
1313
const INACTIVE_POLLING_INTERVAL = ACTIVE_POLLING_INTERVAL * 20; // 10 minutes
1414

1515
export const useNotificationsStore = defineStore("notificationsStore", () => {
16-
const { startWatchingResource: startWatchingNotifications } = useResourceWatcher(getNotificationStatus, {
17-
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
18-
longPollingInterval: INACTIVE_POLLING_INTERVAL,
19-
});
16+
const { startWatchingResource: startWatchingNotifications, stopWatchingResource: stopWatchingNotifications } =
17+
useResourceWatcher(getNotificationStatus, {
18+
shortPollingInterval: ACTIVE_POLLING_INTERVAL,
19+
longPollingInterval: INACTIVE_POLLING_INTERVAL,
20+
});
2021
const broadcastsStore = useBroadcastsStore();
2122

2223
const totalUnreadCount = ref<number>(0);
@@ -106,5 +107,6 @@ export const useNotificationsStore = defineStore("notificationsStore", () => {
106107
updateNotification,
107108
updateBatchNotification,
108109
startWatchingNotifications,
110+
stopWatchingNotifications,
109111
};
110112
});

0 commit comments

Comments
 (0)