Skip to content

fix: resolve race conditions when refreshing access token #203

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

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions src/GoTrueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,11 +615,32 @@ export default class GoTrueClient {
}
}

private _callRefreshTokenPromises: {
[refresh_token: string]: ReturnType<GoTrueClient['_callRefreshTokenConcurrently']>
} = {}

// If there is an ongoing request a promise resolving to it will be returned.
//
// This is necessary because concurrent token refreshes will race and inevitably fail:
// - earliest request will revoke current session and start a new one
// - later requests will fail as they were sent with an old session
// - this will cause new session to also be revoked due to refresh token reuse detection
// - the client will be left with a valid access_token but revoked refresh_token
// - they might not notice it until they try to refresh the token
private async _callRefreshToken(refresh_token = this.currentSession?.refresh_token) {
if (!refresh_token) {
return { data: null, error: { message: 'No current session', status: 401 } as ApiError }
}
if (!(refresh_token in this._callRefreshTokenPromises)) {
this._callRefreshTokenPromises[refresh_token] = this._callRefreshTokenConcurrently(
refresh_token
).finally(() => delete this._callRefreshTokenPromises[refresh_token])
}
return this._callRefreshTokenPromises[refresh_token]
}

private async _callRefreshTokenConcurrently(refresh_token: string) {
try {
if (!refresh_token) {
throw new Error('No current session.')
}
const { data, error } = await this.api.refreshAccessToken(refresh_token)
if (error) throw error
if (!data) throw Error('Invalid session data.')
Expand Down
33 changes: 33 additions & 0 deletions test/GoTrueClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,39 @@ describe('GoTrueClient', () => {
expect(data).toHaveProperty('refresh_token')
expect(refreshToken).not.toEqual(data?.refresh_token)
})

test('refreshSession() can be called concurrently', async () => {
const { email, password } = mockUserCredentials()

const { error: initialError, session } = await authWithSession.signUp({
email,
password,
})

expect(initialError).toBeNull()
expect(session).not.toBeNull()

const initialRefreshToken = session?.refresh_token

const refreshSessionA = authWithSession.refreshSession()
const refreshSessionB = authWithSession.refreshSession()
await new Promise((resolve) => setTimeout(resolve, 0))
const refreshSessionC = authWithSession.refreshSession()

const sessionC = await refreshSessionC
const sessionB = await refreshSessionB
const sessionA = await refreshSessionA

for (const { error, user, data } of [sessionA, sessionB, sessionC]) {
expect(error).toBeNull()
expect(user).not.toBeNull()
expect(data).not.toBeNull()

expect(user?.email).toEqual(email)
expect(data).toHaveProperty('refresh_token')
expect(initialRefreshToken).not.toEqual(data?.refresh_token)
}
})
})

describe('Authentication', () => {
Expand Down