Automail icon indicating copy to clipboard operation
Automail copied to clipboard

Session Keepalive?

Open Koopzington opened this issue 3 years ago • 45 comments

Since i occasionally use AL in a way that involves a bunch of tabs which don't get attention for some time i regularly run into the wellknown "Session expired, please refresh" issue. So i've been thinking: Would adding "automatic session token refreshing" be a possible feature for the script? I haven't been digging too deep into this but i found out it was stored in window.al_token. So in theory you could create a hidden iframe, load AL in there and then update the token through window.al_token = iframe.contentWindow.al_token;. I haven't tested this though. Do you figure this workaround would be worth looking into or should we just wait until AL takes care of this issue themselves?

Koopzington avatar Apr 15 '21 13:04 Koopzington

This sounds interesting, as it's an annoying part of the UX.

Definitely worth looking into, but a lot of stuff has to be tested first.. Loading an iframe sounds like it may have excessive performance implications. Perhaps a cheaper way of getting those tokens exists? Maybe sending postmessage messages with updated tokens to old tabs will work?

It's not even certain that just updating the token will fix the issue, as the native javascript may be keeping track of it in some other ways.

Experimental results will be highly appreciated!

hohMiyazawa avatar Apr 15 '21 13:04 hohMiyazawa

Posting a couple of example tokens may have a small probability of making this trivial. They are most likely random, but in the off chance they encode a timestamp or something, the solution will be so much easier.

I don't think there are any security implications, since the auth system is separate.

hohMiyazawa avatar Apr 15 '21 13:04 hohMiyazawa

Ultimately the new token will be present in the result HTML in the form of a <script>window.al_token = "token";</script>. So there should be plenty of alternatives to iframes, like making the request to AL through the Fetch API and then extracting the token through string operations, however i don't know what the site will do if you do that without a valid token as it also likes to autorefresh, whatever the trigger for this may be.

Koopzington avatar Apr 15 '21 13:04 Koopzington

Questions that need answers:

  1. When are new tokens created?
  2. How long does it take for a token to be expired?
  3. What happens when one tampers with window.al_token?
  4. Where can tokens be harvested from?

hohMiyazawa avatar Apr 15 '21 13:04 hohMiyazawa

Experiments:

a. Setting window.al_token to a fresh token in an expired tab. b. Setting window.al_token to an invalid value.

hohMiyazawa avatar Apr 15 '21 13:04 hohMiyazawa

Ultimately the new token will be present in the result HTML in the form of a <script>window.al_token = "token";</script>. So there should be plenty of alternatives to iframes, like making the request to AL through the Fetch API and then extracting the token through string operations, however i don't know what the site will do if you do that without a valid token as it also likes to autorefresh, whatever the trigger for this may be.

This seems possible. Should perform much better than rendering the page and executing all the native javascript.

hohMiyazawa avatar Apr 15 '21 13:04 hohMiyazawa

b) Makes native use of the API fail until something is refreshed again.

hohMiyazawa avatar Apr 15 '21 14:04 hohMiyazawa

But at least the site does not autorefresh instantly when the token is changed to something invalid.

hohMiyazawa avatar Apr 15 '21 14:04 hohMiyazawa

That confirms that anilist is checking window.al_token after creation though! Very nice.

hohMiyazawa avatar Apr 15 '21 14:04 hohMiyazawa

I imagine two types of script functionality are possible:

  1. Broadcast session tokens from newly refreshed tabs to old stale tabs. Will solve some of the issue, but will not prevent the session expiring if you go away from the computer for a while.

  2. Regular fetching of new session tokens in the background.

hohMiyazawa avatar Apr 15 '21 14:04 hohMiyazawa

another thing i noticed is that you don't get a new token on every refresh so in theory you should be able to do the same thing in every tab and always get the current, valid token instead of developing any cross-tab-communication shenanigans.

Koopzington avatar Apr 15 '21 14:04 Koopzington

I already have cross-tabl-communication shenanigans :3 The fewer requests sent the better.

But yeah, as it's just requesting a couple of hundred bytes of HTML it should be fine. Currently trying to time how long a token lasts. Do you have any numbers?

hohMiyazawa avatar Apr 15 '21 14:04 hohMiyazawa

As expected, different browsers open have different access tokens. Tokens appear to be random in content. I still have the same token as 40min ago.

hohMiyazawa avatar Apr 15 '21 14:04 hohMiyazawa

And now I got a new one.

hohMiyazawa avatar Apr 15 '21 14:04 hohMiyazawa

Updating a stale tab with a new access token, and then crossing out the "session expired" message appears to work fine.

hohMiyazawa avatar Apr 15 '21 14:04 hohMiyazawa

Do you prefer to be named or not in a shout-out post?

hohMiyazawa avatar Apr 15 '21 14:04 hohMiyazawa

feel free to, i don't mind <3 and thanks for the hard work

Koopzington avatar Apr 15 '21 14:04 Koopzington

If tokens can't be issued until the previous one expires, it's inevitable to have those "session expired" messages from time to time during regular browsing. (Unless one frequently polls, and I prefer to not do that).

But the old stale tabs problem, or leaving the computer for a while issue could be solved this way.

hohMiyazawa avatar Apr 15 '21 14:04 hohMiyazawa

can you maybe listen for the event that triggers the display of the message? and uh... just hide it with CSS? :P

Koopzington avatar Apr 15 '21 14:04 Koopzington

That may actually work very well. Detect it, hide it, and resolve the problem silently.

hohMiyazawa avatar Apr 15 '21 14:04 hohMiyazawa

Now I just have to wait until another token expires, see where the message is occurring, and then make a mutationObserver for that.

Then do some testing until I'm confident it works, and then make a css rule to just hide it :)

hohMiyazawa avatar Apr 15 '21 14:04 hohMiyazawa

Experimental implementation: https://github.com/hohMiyazawa/Automail/commit/015d9534b33a2d177937c64f7f229f7ca6fe2620

hohMiyazawa avatar Apr 15 '21 18:04 hohMiyazawa

Issue: Anilist seems really keen on autorefreshing. That has to be stopped, somehow.

hohMiyazawa avatar Apr 15 '21 19:04 hohMiyazawa

To get back on this... the autorefresh get's triggered by the 403. In the main.js you can find the following (formatted through chrome dev tools) image This may look confusing at first sight but it's essentially an if that checks wether the last time the refresh of the al_token happened and if it's been over a minute ago, the page refreshes. Which means... we'd need to localStorage.setItem("session-reload", Date.now()); before the 403 happens...uh...

Okay this is silly but i guess we have no other choice than doing the "You have unsaved changes" route by doing something like

addEventListener('beforeunload', function(e){
    e.preventDefault();
    // Get the new al_token
    // Revert the damage caused by the 403
    // Return something because browser demand it despite no longer showing the string.
    return e.returnValue = "we don't do that here";
});

What i mean by "damage" is things like reverting eternal loading animations back into "Load More" buttons in case of infinity scrolls. Not sure if any other things on the page need to get fixed in case of a 403.

If you want to analyze that you can just invalidate the al_token and localStorage.setItem("session-reload", aTimestampWayIntotheFuture);, then cause the 403.

Koopzington avatar Jun 30 '21 10:06 Koopzington

In what other cases would a page reload be triggered? Do we want to intercept all those?

hohMiyazawa avatar Jun 30 '21 11:06 hohMiyazawa

Do you think something along these lines is appropriate?

// Revert the damage caused by the 403
new MutationObserver(function(){
	let messages = Array.from(document.querySelectorAll(".el-message--error.is-closable"));
	if(messages.some(message => message.textContent === "Session expired, please refresh")){
		message.querySelector(".el-message__closeBtn").click()
	}
}).observe(
	document.body,
	{attributes: false, childList: true, subtree: false}
)

addEventListener("beforeunload", function(e){
	e.preventDefault();
	let oldSessionReload = localStorage.getItem("session-reload");
	// set timestamp immediately, so Anilist doesn't reload the page
	localStorage.setItem("session-reload", Date.now());
	// Get the new al_token
	fetch("index.html").then(function(response){
		return response.text()
	}).then(function(html){
		let token = html.match(/window\.al_token\ =\ "([a-zA-Z0-9]+)";/);
		console.log("token",token);
		if(!token){
			//idk, stuff changed, better clean up after the failed attempt
			localStorage.setItem("session-reload", oldSessionReload);
			return
		}
		window.al_token = token;
		//alert the other tabs so they don't have to do the same
		aniCast.postMessage({type:"sessionToken",value:token});
	}).catch(function(){
		//fail silently, but clean up, trust Anilist to do the right thing by default
		localStorage.setItem("session-reload", oldSessionReload)
	})
	// Return something because browser demand it despite no longer showing the string.
	return e.returnValue = "we don't do that here";
});

hohMiyazawa avatar Jun 30 '21 11:06 hohMiyazawa

Reload interception issues:

https://github.com/hohMiyazawa/Automail/blob/master/src/modules/settingsPage.js#L583

https://github.com/hohMiyazawa/Automail/blob/master/src/modules/ALbuttonReload.js#L6

hohMiyazawa avatar Jun 30 '21 11:06 hohMiyazawa

Or maybe:

addEventListener("beforeunload", function(e){
	let messages = Array.from(document.querySelectorAll(".el-message--error.is-closable"));
	if(messages.some(message => message.textContent === "Session expired, please refresh")){
		// Revert the damage caused by the 403
		message.querySelector(".el-message__closeBtn").click()
	}
	else{
		// not the reload we are looking for
		return
	}
	e.preventDefault();
	let oldSessionReload = localStorage.getItem("session-reload");
	// set timestamp immediately, so Anilist doesn't reload the page
	localStorage.setItem("session-reload", Date.now());
	// Get the new al_token
	fetch("index.html").then(function(response){
		return response.text()
	}).then(function(html){
		let token = html.match(/window\.al_token\ =\ "([a-zA-Z0-9]+)";/);
		console.log("token",token);
		if(!token){
			//idk, stuff changed, better clean up after the failed attempt
			localStorage.setItem("session-reload", oldSessionReload);
			return
		}
		window.al_token = token;
		//alert the other tabs so they don't have to do the same
		aniCast.postMessage({type:"sessionToken",value:token});
	}).catch(function(){
		//fail silently, but clean up, trust Anilist to do the right thing by default
		localStorage.setItem("session-reload", oldSessionReload)
	})
	// Return something because browser demand it despite no longer showing the string.
	return e.returnValue = "we don't do that here";
});

hohMiyazawa avatar Jun 30 '21 11:06 hohMiyazawa

Hmm... i'm not sure if we can actually do much in the beforeunload listener, especially the asynchronous nature of the fetch will cause issues. I think a better approach would be introducing a global variable with which we can either deny or allow the refresh.

new MutationObserver(function(){
    let messages = Array.from(document.querySelectorAll(".el-message--error.is-closable"));
    if(messages.some(message => message.textContent === "Session expired, please refresh")){
        message.querySelector(".el-message__closeBtn").click()
        // update timestamp and al_token
	// Revert the damage caused by the 403
    }
}).observe(
    document.body,
    {attributes: false, childList: true, subtree: false}
)

let allowRefresh = false;
addEventListener("beforeunload", function(e){
    e.preventDefault();
    if (allowRefresh) {
        return;
    }
    // Return something because browser demand it despite no longer showing the string.
    return e.returnValue = "we don't do that here";
});
// Add eventlistener for Keypresses of F5 or CTRL + R which set allowRefresh to true
// Set allowRefresh to true in other parts of Automail that do window.location.reload()

Koopzington avatar Jun 30 '21 14:06 Koopzington

I was counting on using the "session expired" message to detect if this is a refresh to be blocked.

From your disassembly, it looks like the message is displayed before the refresh is initiated.

For the asynchronous complication, doesn't it make sense to just keep blocking the refresh until the fetch resolves?

hohMiyazawa avatar Jun 30 '21 15:06 hohMiyazawa