Merge pull request #5004 from nichwall/token_refresh_race_condition

Access token refresh grace period
This commit is contained in:
advplyr 2026-05-17 14:16:58 -05:00 committed by GitHub
commit cac74f3477
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 181 additions and 18 deletions

View file

@ -183,20 +183,56 @@ class TokenManager {
* @param {import('../models/User')} user
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {boolean} gracePeriod - whether to use the grace period
* @returns {Promise<{ accessToken:string, refreshToken:string }>}
*/
async rotateTokensForSession(session, user, req, res) {
// Generate new tokens
async rotateTokensForSession(session, user, req, res, gracePeriod = true) {
const previousRefreshToken = session.refreshToken
const newAccessToken = this.generateTempAccessToken(user)
const newRefreshToken = this.generateRefreshToken(user)
// Calculate new expiration time
let newRefreshToken = this.generateRefreshToken(user)
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
// Update the session with the new refresh token and expiration
session.refreshToken = newRefreshToken
session.expiresAt = newExpiresAt
await session.save()
let lastRefreshToken = null
let lastRefreshTokenExpiresAt = null
if (gracePeriod) {
// Set grace period of old refresh token in case of race condition in token rotation.
// This grace period may need to be longer if fetching the user data takes longer due to large progress objects
lastRefreshToken = previousRefreshToken
lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period
}
// Only update if this session row still has the refresh token we read
const [numUpdated] = await Database.sessionModel.update(
{
refreshToken: newRefreshToken,
expiresAt: newExpiresAt,
lastRefreshToken,
lastRefreshTokenExpiresAt
},
{
where: {
id: session.id,
refreshToken: previousRefreshToken
}
}
)
if (numUpdated === 0) {
Logger.debug(`[TokenManager] Race condition in rotateTokensForSession for user ${user.id}, getting new token`)
const updatedSession = await Database.sessionModel.findOne({ where: { id: session.id } })
newRefreshToken = updatedSession.refreshToken
session.refreshToken = updatedSession.refreshToken
session.expiresAt = updatedSession.expiresAt
session.lastRefreshToken = updatedSession.lastRefreshToken
session.lastRefreshTokenExpiresAt = updatedSession.lastRefreshTokenExpiresAt
} else {
session.refreshToken = newRefreshToken
session.expiresAt = newExpiresAt
session.lastRefreshToken = lastRefreshToken
session.lastRefreshTokenExpiresAt = lastRefreshTokenExpiresAt
}
// Set new refresh token cookie
this.setRefreshTokenCookie(req, res, newRefreshToken)
@ -294,8 +330,10 @@ class TokenManager {
}
}
const session = await Database.sessionModel.findOne({
where: { refreshToken: refreshToken }
let session = await Database.sessionModel.findOne({
where: {
[Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }]
}
})
if (!session) {
@ -305,12 +343,27 @@ class TokenManager {
}
}
// Check if session is expired in database
if (session.expiresAt < new Date()) {
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
await session.destroy()
return {
error: 'Refresh token expired'
let isGracePeriod = false
if (session.refreshToken !== refreshToken) {
// Token matched lastRefreshToken
if (session.lastRefreshTokenExpiresAt && session.lastRefreshTokenExpiresAt > new Date()) {
isGracePeriod = true
Logger.debug(`[TokenManager] Grace period hit for user ${session.userId}`)
} else {
Logger.debug(`[TokenManager] Grace period expired for user ${session.userId}`)
return {
error: 'Invalid refresh token'
}
}
} else {
// Token matched current refreshToken
// Check if session is expired in database
if (session.expiresAt < new Date()) {
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
await session.destroy()
return {
error: 'Refresh token expired'
}
}
}
@ -322,6 +375,20 @@ class TokenManager {
}
}
if (isGracePeriod) {
// Return the already rotated refresh token store in the database,
// and generate a new access token without changing the refresh token
// again
const accessToken = this.generateTempAccessToken(user)
this.setRefreshTokenCookie(req, res, session.refreshToken)
return {
accessToken,
refreshToken: session.refreshToken,
user
}
}
const newTokens = await this.rotateTokensForSession(session, user, req, res)
return {
accessToken: newTokens.accessToken,
@ -375,7 +442,7 @@ class TokenManager {
// So rotate token for current session
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
if (currentSession) {
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false)
// Invalidate all sessions for the user except the current one
await Database.sessionModel.destroy({