diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index faa6774a3..12a92903c 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -183,20 +183,44 @@ 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) { + async rotateTokensForSession(session, user, req, res, gracePeriod = true) { // Generate new tokens const newAccessToken = this.generateTempAccessToken(user) - const newRefreshToken = this.generateRefreshToken(user) + let newRefreshToken = this.generateRefreshToken(user) - // Calculate new expiration time - const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) + 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 + 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 - 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) @@ -287,8 +311,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 +324,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 +356,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, @@ -368,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) + const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false) // Invalidate all sessions for the user except the current one await Database.sessionModel.destroy({ 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 } }, {