mirror of
https://github.com/zellij-org/zellij.git
synced 2026-10-11 22:50:07 +00:00
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user