Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support refresh token syncing for multiple tabs #444

Closed

Conversation

miguelespinoza
Copy link

@miguelespinoza miguelespinoza commented Sep 19, 2022

What kind of change does this PR introduce?

This change introduces a mechanism to sync refresh tokens at the client level, rather than depending on the backend.
The current behavior of supabase-js v2 unfortunately continues to log out users. This was reproducible with at least three tabs. More information here: #454 and #442

Implementation Details

The sync logic uses this.storage to cache the status of the refresh token request. The only properties stored are refresh_token and status. Check SyncTokenRefreshStorage.

  • TOKEN_REFRESHING is set on status before the API request is made.
  • TOKEN_REFRESHED is set on status after the API request resolved.

For any tab that attemps to refresh the token.

  • If status in storage is TOKEN_REFRESHING. It waits until token has refreshed, then pulls latest session from storage
  • If status in storage is TOKEN_REFRESHED. it pulls latest session from storage
  • In both cases, the latestSession is validated, to check if it’s not about to expire.

What is the current behavior?

The user is logged out with at least three tabs: #454
Notice a burst of network requests to the API. Eventually, this causes a 400 error, logging out the user. Check timestamps.

  • This screenshot represents 8 instances, lasting only 9 minutes before timing out.

cleanshot_09_19_at_14_54@2x

What is the new behavior?

The tabs depend on only one request to succeed. The remainder tabs wait until the refresh token is retrieved from the API to resolve. Providing a clean POST request of one to the API. Check timestamps.

cleanshot_09_19_at_15_12@2x

Additional context

  • Multi-requests can still happen if the timer for tabs have an identical cycle. This is where we would fallback on the backend with the reuse interval logic
  • Another idea I had was to idle the supabase instance if the user put the tab in the background. I was concerned this approach would introduce unwanted behaviors for devs that use supabase requests while in the background. Let me know if this is worth pursuing. Just throwing that idea out there.
  • For debugging I found mitmproxy extremely helpful to view the requests
  • Here are the auth configuration changes I tested. 30 second JWT expiry and 10 second reuse interval
  • Definitely a preliminary change. I'd love to get feedback and potential gotchas. So far with testing, no 400 errors. I plan on testing this further with a production app I'm building. As this was causing me a bit of headache. The production app is currently running supabase-js v1, but I still found issues in v2.

Edit: Tue Sept 20 - Included Implementation Details

}

await this._saveSession(syncedSession)
this._notifyAllSubscribers('TOKEN_REFRESHED', syncedSession)
Copy link
Author

@miguelespinoza miguelespinoza Sep 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally thought about setting the refreshToken sync status inside an onAuthStateChange listener.
But I didn't see an example of this inside this class, so figured just to leave it as is. The idea required:

  • creating a listener for TOKEN_REFRESHING and TOKEN_REFRESHED in the constructor. In there call setItemAsync({..., status: "TOKEN_REFRESHING"})
  • Adding TOKEN_REFRESHING to type AuthChangeEvent

Instead, I went with the change localized to _callRefreshToken open to suggestions

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this implementation is fine, imo TOKEN_REFRESHING seems like an implementation detail and we don't have to add it to the AuthChangeEvent type.

resolve()
}, 1500)
})
}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw there's an exponential backoff for network retry.
I didn't think it was required here. A request to refresh a token takes on average 150-300ms.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @miguelespinoza, just to clarify, the purpose of refreshTokenSyncIntervalTimer is to continue check the storage to see if the status has changed from TOKEN_REFRESHING to ``TOKEN_REFRESHED` right?

And we have refreshTokenSyncTimeoutTimer to eventually cleanup the setInterval. At this point, if the callback in setTimeout is invoked, the status should still be TOKEN_REFRESHING right?


const result = { session: data.session, error: null }
const { data, error } = await this._refreshAccessToken(refreshToken)
if (error) throw error
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm looking closer into this case when an error occurs with the request.
So far with a network disconnection, calls resolve appropriately.
I just want to make sure everything is well accounted for, so just sharing what's next on my list to validate

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, this.refreshTokenSyncStorageKey is removed from storage when an error is thrown.
I tested this case using network disconnection. If the request failed we'll delete the refresh token sync status.
This will have a domino effect on all tabs:

  • Tabs finish waiting on TOKEN_REFRESHING status to change.
  • Tabs will attempt to make a network request. It will fail
  • Once one tab makes a successful network request. The tab waits. If successful avoid network request and use the latestSession

@miguelespinoza
Copy link
Author

I'll hold on updating any test until we're all cool with these changes.
In the meantime, I'll be testing this change very soon in production 🤞

@miguelespinoza
Copy link
Author

Follow-up conversation from Discord: https://discord.com/channels/839993398554656828/1019328795129421944

On chrome/firefox and I'm sure mobile, timers get throttled in background.
https://developer.chrome.com/blog/timer-throttling-in-chrome-88/#intensive-throttling
You have potentially alot of timers that will get throttled to 1 minute (realtime breaks in background because of loss of heartbeat for instance). Need to think thru what that could do here. Also on mobile these tabs might not run at all until they become visible again. gotrue-js added a check for coming back from hidden recently.
From @GaryAustin1

This is a good assessment. I'd like to avoid any unnecessary work that could use up resources. The chrome documentation talks about event changes. This is what the multiTab option supported. Could we get an explanation for why this was removed?

Another add-on could be idling the API request to refresh token if the tab is in the background? Any concerns here? @GaryAustin1 mentions realtime logic takes a break when in the background. We could apply the same behavior to the refresh token logic.

@GaryAustin1
Copy link

Just to clarify on realtime in background... It does not take a break. I have code using a visibility handler to make it "take a break" by stopping it before the background timing errors occur (5 minutes).

@miguelespinoza
Copy link
Author

There's been an awesome coversation on Discord. Started here
So here's a recap:

  • gut feeling from a few members, this will cause timing issues due to throttling from browsers. It's a very good point
  • "Wouldn't it be better to just, check here if there is a new session in storage?" Maybe that's the trick, but I'm curious why was multiTab removed. Was that to put full dependency on the backend with reuse interval support?
  • "if i pass a custom InMemoryStorage i won't need the checks at all." Yup, I made a suggestion to add a flag refreshTokenSync that enables this logic
  • Remove the interval polling all together and just check if the session has refreshed before making a network request.

Another thing: if i pass a custom InMemoryStorage i won't need the checks at all. So my thinking goes that handling multitab is related to the storage in use. Therefore the actual storage should implement the handling of expired refresh tokens. Eg for local storage this could be solved with storage events (https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) or eventually with a SharedWorker (https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker?retiredLocale=de).
For your case this could potentially solved with storage.onChanged event (https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/onChanged) or a SharedWorker as well. Not sure how that would be solved in ract native (is there even a multitab scenario?).

Relying on localStorage events was supported in v1, but removed in v2. I don't know it's history, so it would be great to understand the decision for removing multiTab support. With these issues encountered, it almost feels like we need to re-introduce _listenForMultiTabEvents

All in all, this information is helping towards finding an optimal solution.

  • I have a feeling this requires another review in the backend to understand the 400 errors: Getting 400 on /auth/v1/token?grant_type=refresh_token #454
  • This change may not be adequate, mainly because of the interval polling, but maybe we take some pieces like checking if there's a new session in storage before making a request. That's a small change
  • Re-introduce multiTab support

@miguelespinoza
Copy link
Author

Here's my realization. For a chrome extension that uses supabase on multiple tabs. I need to opt out of the autoRefreshToken option and handle that on my own, using browser native methods.

  • Instead of a setTimeout. Use chrome.alarm to trigger an event 10 seconds before token expiration.
  • On alarm received, refresh token manually
  • Update that in chrome.storage. Which is passed to supabase using the storage option
  • Update remainder tabs using storage.onChange picks up the new token in other tabs. I need to figure out if/how we can bring back _listenForMultiTabEvents and make it abstract so any storage engine can support this behavior.

If this works, I wouldn't require the PR changes anymore. But I'm sure others would benefit from a fix on the library because this has been reproduced in a regular project: #442

@miguelespinoza
Copy link
Author

miguelespinoza commented Sep 21, 2022

Here's a WIP implementation of refresh token logic for browser extensions.
https://gist.github.com/miguelespinoza/59d8dc57aaeae27ffac87989f06e8f0e

It's not complete, but the core is in the gist. Two gotchas:

  1. Setting autoRefreshToken: false causes the library to remove the token if the extension starts with network disconnection
    • It's due to this else statement when calling _recoverAndRefresh.
    • Is the team open to an option like refreshTokenHandledByClient, to allow the app to handle refresh token logic? I'm open to suggestions
  2. I originally started to add support for browser.storage.onChange. This was going to listen to token changes in storage. But I noticed that a supabase request calls auth.getSession(). Is it safe to say I don't need to handle token syncing, because supabse.from... queries for an up-to-date auth token?

This trace would indicate fetch ends up calling supabase.auth.getSession() on each request. I have to pinpoint where in the library this happens..

EDIT: Found where fetch calls auth.getSession. It happens via fetchWithAuth 👌

cleanshot_09_21_at_15_29@2x

Copy link
Member

@kangmingtay kangmingtay left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @miguelespinoza, thanks for the detailed explanation and taking the time to investigate this issue. I've tested this PR on my end with the JWT_EXPIRY set to 20s, REUSE_INTERVAL set to 10s with 4 tabs and it's looking good.

We got rid of the multi-tab property because when we tested the v2 implementation initially, we couldn't reproduce the error. Also, i don't think the multiTab property would've helped in this scenario to prevent a refresh outside the reuse interval. It was mainly used to add a listener to the storage event.

resolve()
}, 1500)
})
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @miguelespinoza, just to clarify, the purpose of refreshTokenSyncIntervalTimer is to continue check the storage to see if the status has changed from TOKEN_REFRESHING to ``TOKEN_REFRESHED` right?

And we have refreshTokenSyncTimeoutTimer to eventually cleanup the setInterval. At this point, if the callback in setTimeout is invoked, the status should still be TOKEN_REFRESHING right?

}

await this._saveSession(syncedSession)
this._notifyAllSubscribers('TOKEN_REFRESHED', syncedSession)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this implementation is fine, imo TOKEN_REFRESHING seems like an implementation detail and we don't have to add it to the AuthChangeEvent type.

@@ -105,6 +113,7 @@ export default class GoTrueClient {
const settings = { ...DEFAULT_OPTIONS, ...options }
this.inMemorySession = null
this.storageKey = settings.storageKey
this.refreshTokenSyncStorageKey = `${this.storageKey}-rts`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it possible to actually storage this information in the same storage used for the session?

@kangmingtay
Copy link
Member

One thing i'm trying to figure out is how the client can get to the point where one of the refreshes happen outside the reuse interval. 10 seconds is a long time and i suspect that the reason for this is because the session passed into the _startAutoRefreshToken method is outside of the reuse interval at the point in time where the callback is invoked.

@kangmingtay
Copy link
Member

Hey @miguelespinoza, i think i've figured out the issue, seems like the session passed into the _startAutoRefreshToken method can possibly become stale and fall outside the reuse interval. I've made a PR here. Would be awesome if you can try it out and see if it fixes the problem for you!

@miguelespinoza
Copy link
Author

miguelespinoza commented Oct 3, 2022

@kangmingtay Thanks for submitting a PR. I think that would be a better change than this. Your change is small, and it's a change I advocated for after submitting this PR.

I've since gone with a different solution commented here.
I'm using the chrome.alarms API to mimic the timer in the library. It'll be a stable solution moving forward when I migrate my extension to MV3 which uses a service worker and shies away from timers. More details here. This also has the added benefit of only one instance handling the token refresh logic rather than the first come first serve style.

Others had this issue too, so I'm learning on them to help with testing 😁.

While I have your attention, I'm working on another project and had to downgrade from v2 to v1, because of this tangential auth issue: #450. Just sharing just in case, if it's not already on your radar

@kangmingtay
Copy link
Member

hey @miguelespinoza, yup we're gonna look into #450 next, thanks for taking the time and effort to make this PR though!

@kangmingtay kangmingtay closed this Oct 4, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants