From da0a64daed44697e020ff734c53cee6e8b7fab43 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 24 Jan 2026 16:57:25 -0700 Subject: [PATCH 1/6] Add: 10 second grace period to access token cycle --- server/auth/TokenManager.js | 56 ++++++++++--- .../v2.33.0-add-last-refresh-token.js | 84 +++++++++++++++++++ server/models/Session.js | 12 +++ 3 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 server/migrations/v2.33.0-add-last-refresh-token.js diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index faa6774a3..5d8683553 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -12,9 +12,9 @@ class TokenManager { constructor() { /** @type {number} Refresh token expiry in seconds */ - this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days + this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 3 // 3 seconds for testing /** @type {number} Access token expiry in seconds */ - this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour + this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 60 // 60 seconds for testing if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`) @@ -193,6 +193,11 @@ class TokenManager { // Calculate new expiration time const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) + // 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 + session.lastRefreshToken = session.refreshToken + session.lastRefreshTokenExpiresAt = new Date(Date.now() + 10 * 1000) // 10 seconds grace period + // Update the session with the new refresh token and expiration session.refreshToken = newRefreshToken session.expiresAt = newExpiresAt @@ -287,8 +292,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) { @@ -298,12 +305,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' + } } } @@ -315,6 +337,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, diff --git a/server/migrations/v2.33.0-add-last-refresh-token.js b/server/migrations/v2.33.0-add-last-refresh-token.js new file mode 100644 index 000000000..5b53c749a --- /dev/null +++ b/server/migrations/v2.33.0-add-last-refresh-token.js @@ -0,0 +1,84 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.33.0' +const migrationName = `${migrationVersion}-add-last-refresh-token` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This migration script adds lastRefreshToken and lastRefreshTokenExpiresAt columns to the sessions table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('sessions')) { + const tableDescription = await queryInterface.describeTable('sessions') + + if (!tableDescription.lastRefreshToken) { + logger.info(`${loggerPrefix} Adding lastRefreshToken column to sessions table`) + await queryInterface.addColumn('sessions', 'lastRefreshToken', { + type: queryInterface.sequelize.Sequelize.DataTypes.STRING, + allowNull: true + }) + } else { + logger.info(`${loggerPrefix} lastRefreshToken column already exists in sessions table`) + } + + if (!tableDescription.lastRefreshTokenExpiresAt) { + logger.info(`${loggerPrefix} Adding lastRefreshTokenExpiresAt column to sessions table`) + await queryInterface.addColumn('sessions', 'lastRefreshTokenExpiresAt', { + type: queryInterface.sequelize.Sequelize.DataTypes.DATE, + allowNull: true + }) + } else { + logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column already exists in sessions table`) + } + } else { + logger.info(`${loggerPrefix} sessions table does not exist`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This migration script removes the lastRefreshToken and lastRefreshTokenExpiresAt columns from the sessions table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('sessions')) { + const tableDescription = await queryInterface.describeTable('sessions') + + if (tableDescription.lastRefreshToken) { + logger.info(`${loggerPrefix} Removing lastRefreshToken column from sessions table`) + await queryInterface.removeColumn('sessions', 'lastRefreshToken') + } else { + logger.info(`${loggerPrefix} lastRefreshToken column does not exist in sessions table`) + } + + if (tableDescription.lastRefreshTokenExpiresAt) { + logger.info(`${loggerPrefix} Removing lastRefreshTokenExpiresAt column from sessions table`) + await queryInterface.removeColumn('sessions', 'lastRefreshTokenExpiresAt') + } else { + logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column does not exist in sessions table`) + } + } else { + logger.info(`${loggerPrefix} sessions table does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/models/Session.js b/server/models/Session.js index fe9dd5425..3b85bd46a 100644 --- a/server/models/Session.js +++ b/server/models/Session.js @@ -18,6 +18,10 @@ class Session extends Model { this.userId /** @type {Date} */ this.expiresAt + /** @type {string} */ + this.lastRefreshToken + /** @type {Date} */ + this.lastRefreshTokenExpiresAt // Expanded properties @@ -66,6 +70,14 @@ class Session extends Model { expiresAt: { type: DataTypes.DATE, allowNull: false + }, + lastRefreshToken: { + type: DataTypes.STRING, + allowNull: true + }, + lastRefreshTokenExpiresAt: { + type: DataTypes.DATE, + allowNull: true } }, { From 7aa2f84daad13fc9f4b7646797385e25cf2f9872 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 24 Jan 2026 17:00:07 -0700 Subject: [PATCH 2/6] Revert default token expiry --- server/auth/TokenManager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 5d8683553..27c7a518b 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -12,9 +12,9 @@ class TokenManager { constructor() { /** @type {number} Refresh token expiry in seconds */ - this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 3 // 3 seconds for testing + this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days /** @type {number} Access token expiry in seconds */ - this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 60 // 60 seconds for testing + this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`) From e1ae4f2d315d153e206edebb8857883af896a5c7 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 24 Jan 2026 18:10:38 -0700 Subject: [PATCH 3/6] Fix: race condition in rotation --- server/auth/TokenManager.js | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 27c7a518b..bf987bb67 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -188,20 +188,32 @@ class TokenManager { async rotateTokensForSession(session, user, req, res) { // Generate new tokens const newAccessToken = this.generateTempAccessToken(user) - const newRefreshToken = this.generateRefreshToken(user) - - // Calculate new expiration time - const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) + let newRefreshToken = this.generateRefreshToken(user) // 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 session.lastRefreshToken = session.refreshToken - session.lastRefreshTokenExpiresAt = new Date(Date.now() + 10 * 1000) // 10 seconds grace period + session.lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period // Update the session with the new refresh token and expiration session.refreshToken = newRefreshToken - session.expiresAt = newExpiresAt - await session.save() + session.expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) + + // Only update the session if the refresh token hasn't changed since we originally read it + const [numUpdated] = await Database.sessionModel.update(session, { + where: { + id: session.id, + refreshToken: session.lastRefreshToken + } + }) + + 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 + } // Set new refresh token cookie this.setRefreshTokenCookie(req, res, newRefreshToken) From b8a2d113f05b180623f6f944f69ea746c9c03a26 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 24 Jan 2026 18:26:11 -0700 Subject: [PATCH 4/6] Allow rotation without grace period for invalidating all user sessions --- server/auth/TokenManager.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index bf987bb67..c36aada08 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -183,17 +183,23 @@ class TokenManager { * @param {import('../models/User')} user * @param {import('express').Request} req * @param {import('express').Response} res - * @returns {Promise<{ accessToken:string, refreshToken:string }>} + * @param {boolean} noGracePeriod - whether to skip the grace period */ - async rotateTokensForSession(session, user, req, res) { + async rotateTokensForSession(session, user, req, res, noGracePeriod = false) { // Generate new tokens const newAccessToken = this.generateTempAccessToken(user) let newRefreshToken = this.generateRefreshToken(user) - // 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 - session.lastRefreshToken = session.refreshToken - session.lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period + if (noGracePeriod) { + // 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 + session.lastRefreshToken = session.refreshToken + session.lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period + } else { + // Do not set grace period of old refresh token, such as when specifically invalidating sessions for a user + session.lastRefreshToken = null + session.lastRefreshTokenExpiresAt = null + } // Update the session with the new refresh token and expiration session.refreshToken = newRefreshToken @@ -416,7 +422,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, true) // Invalidate all sessions for the user except the current one await Database.sessionModel.destroy({ From 077b523bd66fce70639aa8f88d92e2c442115fca Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 24 Jan 2026 18:42:50 -0700 Subject: [PATCH 5/6] Fix JS Doc deletion --- server/auth/TokenManager.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index c36aada08..63463c4b5 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -184,6 +184,7 @@ class TokenManager { * @param {import('express').Request} req * @param {import('express').Response} res * @param {boolean} noGracePeriod - whether to skip the grace period + * @returns {Promise<{ accessToken:string, refreshToken:string }>} */ async rotateTokensForSession(session, user, req, res, noGracePeriod = false) { // Generate new tokens From cfeb6bd502a0f69483db35c37217b8f738c5d029 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 24 Jan 2026 18:57:40 -0700 Subject: [PATCH 6/6] Fix: grace period enable statement --- server/auth/TokenManager.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 63463c4b5..12a92903c 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -183,15 +183,15 @@ class TokenManager { * @param {import('../models/User')} user * @param {import('express').Request} req * @param {import('express').Response} res - * @param {boolean} noGracePeriod - whether to skip the grace period + * @param {boolean} gracePeriod - whether to use the grace period * @returns {Promise<{ accessToken:string, refreshToken:string }>} */ - async rotateTokensForSession(session, user, req, res, noGracePeriod = false) { + async rotateTokensForSession(session, user, req, res, gracePeriod = true) { // Generate new tokens const newAccessToken = this.generateTempAccessToken(user) let newRefreshToken = this.generateRefreshToken(user) - if (noGracePeriod) { + 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 session.lastRefreshToken = session.refreshToken @@ -423,7 +423,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, true) + const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false) // Invalidate all sessions for the user except the current one await Database.sessionModel.destroy({