-
-
Notifications
You must be signed in to change notification settings - Fork 164
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -43,6 +43,7 @@ import type { | |
SignUpWithPasswordCredentials, | ||
Subscription, | ||
SupportedStorage, | ||
SyncTokenRefreshStorage, | ||
User, | ||
UserAttributes, | ||
UserResponse, | ||
|
@@ -71,6 +72,11 @@ export default class GoTrueClient { | |
*/ | ||
protected storageKey: string | ||
|
||
/** | ||
* The storage key used to keep the refresh token synced with tabs | ||
*/ | ||
protected refreshTokenSyncStorageKey: string | ||
|
||
/** | ||
* The session object for the currently logged in user. If null, it means there isn't a logged-in user. | ||
* Only used if persistSession is false. | ||
|
@@ -82,6 +88,8 @@ export default class GoTrueClient { | |
protected storage: SupportedStorage | ||
protected stateChangeEmitters: Map<string, Subscription> = new Map() | ||
protected refreshTokenTimer?: ReturnType<typeof setTimeout> | ||
protected refreshTokenSyncIntervalTimer?: ReturnType<typeof setTimeout> | ||
protected refreshTokenSyncTimeoutTimer?: ReturnType<typeof setTimeout> | ||
protected networkRetries = 0 | ||
protected refreshingDeferred: Deferred<CallRefreshTokenResult> | null = null | ||
/** | ||
|
@@ -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` | ||
this.autoRefreshToken = settings.autoRefreshToken | ||
this.persistSession = settings.persistSession | ||
this.storage = settings.storage || localStorageAdapter | ||
|
@@ -822,24 +831,63 @@ export default class GoTrueClient { | |
} | ||
|
||
try { | ||
let syncedSession: Session | null = null | ||
const refreshTokenSync = (await getItemAsync( | ||
this.storage, | ||
this.refreshTokenSyncStorageKey | ||
)) as SyncTokenRefreshStorage | undefined | ||
|
||
if ( | ||
refreshTokenSync?.refresh_token === refreshToken && | ||
refreshTokenSync?.status === 'TOKEN_REFRESHING' | ||
) { | ||
await this._waitOnRefreshTokenSync() | ||
const latestSession = (await getItemAsync(this.storage, this.storageKey)) as Session | null | ||
if (this._isSessionValid(latestSession)) { | ||
syncedSession = latestSession | ||
} | ||
} else if (refreshTokenSync?.status === 'TOKEN_REFRESHED') { | ||
const latestSession = (await getItemAsync(this.storage, this.storageKey)) as Session | null | ||
if (this._isSessionValid(latestSession)) { | ||
syncedSession = latestSession | ||
} | ||
} | ||
|
||
this.refreshingDeferred = new Deferred<CallRefreshTokenResult>() | ||
|
||
if (!refreshToken) { | ||
throw new AuthSessionMissingError() | ||
} | ||
const { data, error } = await this._refreshAccessToken(refreshToken) | ||
if (error) throw error | ||
if (!data.session) throw new AuthSessionMissingError() | ||
|
||
await this._saveSession(data.session) | ||
this._notifyAllSubscribers('TOKEN_REFRESHED', data.session) | ||
if (!syncedSession) { | ||
// Cache refresh token status for other tabs | ||
await setItemAsync(this.storage, this.refreshTokenSyncStorageKey, { | ||
refresh_token: refreshToken, | ||
status: 'TOKEN_REFRESHING', | ||
}) | ||
|
||
const { data, error } = await this._refreshAccessToken(refreshToken) | ||
if (error) throw error | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok,
|
||
if (!data.session) throw new AuthSessionMissingError() | ||
syncedSession = data.session | ||
|
||
await setItemAsync(this.storage, this.refreshTokenSyncStorageKey, { | ||
refresh_token: syncedSession.refresh_token, | ||
status: 'TOKEN_REFRESHED', | ||
}) | ||
} | ||
|
||
const result = { session: data.session, error: null } | ||
await this._saveSession(syncedSession) | ||
this._notifyAllSubscribers('TOKEN_REFRESHED', syncedSession) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I originally thought about setting the refreshToken sync status inside an
Instead, I went with the change localized to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this implementation is fine, imo |
||
|
||
const result = { session: syncedSession, error: null } | ||
|
||
this.refreshingDeferred.resolve(result) | ||
|
||
return result | ||
} catch (error) { | ||
await removeItemAsync(this.storage, this.refreshTokenSyncStorageKey) | ||
|
||
if (isAuthError(error)) { | ||
const result = { session: null, error } | ||
|
||
|
@@ -855,6 +903,36 @@ export default class GoTrueClient { | |
} | ||
} | ||
|
||
private async _waitOnRefreshTokenSync(): Promise<void> { | ||
return new Promise((resolve) => { | ||
this.refreshTokenSyncIntervalTimer = setInterval(async () => { | ||
const refreshTokenSync = (await getItemAsync( | ||
this.storage, | ||
this.refreshTokenSyncStorageKey | ||
)) as SyncTokenRefreshStorage | undefined | ||
|
||
if (refreshTokenSync?.status === 'TOKEN_REFRESHED') { | ||
clearInterval(this.refreshTokenSyncIntervalTimer) | ||
clearTimeout(this.refreshTokenSyncTimeoutTimer) | ||
resolve() | ||
} | ||
}, 100) | ||
|
||
// Stop interval if tokenSync.status does not change | ||
this.refreshTokenSyncTimeoutTimer = setTimeout(async () => { | ||
await removeItemAsync(this.storage, this.refreshTokenSyncStorageKey) | ||
clearInterval(this.refreshTokenSyncIntervalTimer) | ||
resolve() | ||
}, 1500) | ||
}) | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I saw there's an exponential backoff for network retry. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hey @miguelespinoza, just to clarify, the purpose of And we have |
||
|
||
private _isSessionValid(session: Session | null): boolean { | ||
return session?.expires_at | ||
? session.expires_at - (EXPIRY_MARGIN + 0.5) >= Date.now() / 1000 | ||
: false | ||
} | ||
|
||
private _notifyAllSubscribers(event: AuthChangeEvent, session: Session | null) { | ||
this.stateChangeEmitters.forEach((x) => x.callback(event, session)) | ||
} | ||
|
@@ -888,13 +966,22 @@ export default class GoTrueClient { | |
private async _removeSession() { | ||
if (this.persistSession) { | ||
await removeItemAsync(this.storage, this.storageKey) | ||
await removeItemAsync(this.storage, this.refreshTokenSyncStorageKey) | ||
} else { | ||
this.inMemorySession = null | ||
} | ||
|
||
if (this.refreshTokenTimer) { | ||
clearTimeout(this.refreshTokenTimer) | ||
} | ||
|
||
if (this.refreshTokenSyncIntervalTimer) { | ||
clearInterval(this.refreshTokenSyncIntervalTimer) | ||
} | ||
|
||
if (this.refreshTokenSyncTimeoutTimer) { | ||
clearTimeout(this.refreshTokenSyncTimeoutTimer) | ||
} | ||
} | ||
|
||
/** | ||
|
There was a problem hiding this comment.
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?