fix(web-client): coalesce concurrent authentication onto a single prompt (#5542)

authorizedFetch asks the user for a token every time a request is
rejected, and getSecurityToken appends a dialog to document.body with no
singleton, so concurrent rejections open one dialog each. Authenticating
one does not release the others: each parked call is blocked on its own
await and never re-checks the now-valid cookie.

The standalone session menu surfaces this by polling /session-list every
3s while logged out, stacking a dialog per tick, but the mechanism is not
specific to that caller.

Route rejected callers through one shared pending authentication. The
first owns the prompt, the rest await its result and retry against the
credentials it established.

Requests rejected across an authentication rather than during it need
more than coalescing: a request sent before login is answered against the
credentials of the moment it was sent, so its 401 can arrive once the
shared attempt has already settled and been cleared. Track an
authentication generation, incremented in the same finally that clears
the shared promise, and sample it around each fetch. A rejection that
arrives after the generation advanced predates the credentials now held,
so retry the request rather than prompting again.

app.js, index.html and integrity.json are regenerated by `cargo xtask
assets`.

Refs #5540
This commit is contained in:
Append
2026-08-26 15:12:32 +02:00
committed by GitHub
parent a9bd448e66
commit 035876d7af
4 changed files with 84 additions and 6 deletions
+41 -2
View File
@@ -265,6 +265,37 @@ async function login(token, rememberMe) {
return true;
}
let pendingAuthentication = null;
let authenticationGeneration = 0;
/**
* Authenticate, coalescing concurrent callers onto a single prompt.
*
* Any number of requests may be rejected at once - the session menu polls while
* the page is still booting, for example. Each one asking the user separately
* would stack a dialog per rejection, so the first caller owns the prompt and
* the rest await its result. They all resume together once it settles, and
* retry against the credentials it established.
*
* @returns {Promise<boolean>} true when the login succeeded
*/
function authenticate() {
if (!pendingAuthentication) {
pendingAuthentication = (async () => {
try {
const { token, remember } = await waitForSecurityToken();
return await login(token, remember);
} finally {
// Cleared before the awaiting callers run, so a later rejection
// opens a fresh prompt rather than reusing this settled one.
authenticationGeneration += 1;
pendingAuthentication = null;
}
})();
}
return pendingAuthentication;
}
/**
* Perform a request, authenticating and retrying for as long as it is rejected.
* @param {string} url - Absolute URL to request
@@ -273,6 +304,8 @@ async function login(token, rememberMe) {
*/
async function authorizedFetch(url, options = {}) {
while (true) {
const generation = authenticationGeneration;
const response = await fetch(url, {
credentials: "include",
...options,
@@ -289,8 +322,14 @@ async function authorizedFetch(url, options = {}) {
);
}
const { token, remember } = await waitForSecurityToken();
await login(token, remember);
// A request in flight across an authentication was answered against the
// credentials of the moment it was sent, so its rejection says nothing
// about the ones we hold now. Retry it before asking the user again.
if (authenticationGeneration !== generation) {
continue;
}
await authenticate();
}
}
+41 -2
View File
@@ -61,6 +61,37 @@ async function login(token, rememberMe) {
return true;
}
let pendingAuthentication = null;
let authenticationGeneration = 0;
/**
* Authenticate, coalescing concurrent callers onto a single prompt.
*
* Any number of requests may be rejected at once - the session menu polls while
* the page is still booting, for example. Each one asking the user separately
* would stack a dialog per rejection, so the first caller owns the prompt and
* the rest await its result. They all resume together once it settles, and
* retry against the credentials it established.
*
* @returns {Promise<boolean>} true when the login succeeded
*/
function authenticate() {
if (!pendingAuthentication) {
pendingAuthentication = (async () => {
try {
const { token, remember } = await waitForSecurityToken();
return await login(token, remember);
} finally {
// Cleared before the awaiting callers run, so a later rejection
// opens a fresh prompt rather than reusing this settled one.
authenticationGeneration += 1;
pendingAuthentication = null;
}
})();
}
return pendingAuthentication;
}
/**
* Perform a request, authenticating and retrying for as long as it is rejected.
* @param {string} url - Absolute URL to request
@@ -69,6 +100,8 @@ async function login(token, rememberMe) {
*/
export async function authorizedFetch(url, options = {}) {
while (true) {
const generation = authenticationGeneration;
const response = await fetch(url, {
credentials: "include",
...options,
@@ -85,8 +118,14 @@ export async function authorizedFetch(url, options = {}) {
);
}
const { token, remember } = await waitForSecurityToken();
await login(token, remember);
// A request in flight across an authentication was answered against the
// credentials of the moment it was sent, so its rejection says nothing
// about the ones we hold now. Retry it before asking the user again.
if (authenticationGeneration !== generation) {
continue;
}
await authenticate();
}
}
+1 -1
View File
@@ -19,6 +19,6 @@
<body>
<div id="terminal" tabindex="0"></div>
<script src="assets/modals.js" integrity="sha384-iDRet9veierC1HWbStHC4LLKCSzjeIZTrPRePuKxiUxr7UvdmJ5epb+PYqgHYqte"></script>
<script type="module" src="assets/app.js" integrity="sha384-ltsszp8xHOJbHDX+dpNDmfCym1Er/n7QxGBoDF10vsccseNqSK7lDkkAz1jPVg9U"></script>
<script type="module" src="assets/app.js" integrity="sha384-zEYVY/2DhkJGDOJDhP5qx+pWO6zp80b3z6IXnbDUlNjRjCHgSdwSSwpRU5rQLndq"></script>
</body>
</html>
+1 -1
View File
@@ -3,7 +3,7 @@
"addon-fit.js": "sha384-zZmjmUWgxteKU0acxOtNmpWCMwSnn1/pSIhFBXVx6ARJ4pD/eD3E0dDCccITHWWD",
"addon-web-links.js": "sha384-Zb/L38m4THfRbsxxv8gWAi8cQ6xcWQnv0BgR0qwbTDwtBDaPi6pkyfgfBIH6C8ZG",
"addon-webgl.js": "sha384-AkzdQDeATxpW8V7I2EPcwg17qAoLQMjEFHb//1QaAAIyIlbDAHGKno5+huH7RKxq",
"app.js": "sha384-ltsszp8xHOJbHDX+dpNDmfCym1Er/n7QxGBoDF10vsccseNqSK7lDkkAz1jPVg9U",
"app.js": "sha384-zEYVY/2DhkJGDOJDhP5qx+pWO6zp80b3z6IXnbDUlNjRjCHgSdwSSwpRU5rQLndq",
"modals.js": "sha384-iDRet9veierC1HWbStHC4LLKCSzjeIZTrPRePuKxiUxr7UvdmJ5epb+PYqgHYqte",
"style.css": "sha384-qvSJURWrtlva+87171/c2KHjzg2kzTYgjlEkCLTCdadhZtJA5MyGgt7VgOj9dE0D",
"xterm.css": "sha384-Mz9RDc16Iua5vrzALPqUZn39WVfelFzZvwpqCNkMKji5gpYeP3kNCMPGypMFf0AD",