diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts index 5d6bf21fa2..a3db2d560a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts @@ -34,7 +34,9 @@ export class RefreshTokenService { ) {} async verifyRefreshToken(refreshToken: string) { - const coolDown = this.twentyConfigService.get('REFRESH_TOKEN_COOL_DOWN'); + const reuseGracePeriod = this.twentyConfigService.get( + 'REFRESH_TOKEN_REUSE_GRACE_PERIOD', + ); await this.jwtWrapperService.verifyJwtToken(refreshToken); const jwtPayload = @@ -70,29 +72,36 @@ export class RefreshTokenService { ); } - // Check if revokedAt is less than coolDown - if ( - token.revokedAt && - token.revokedAt.getTime() <= Date.now() - ms(coolDown) - ) { - // Revoke all user refresh tokens - await Promise.all( - user.appTokens.map(async ({ id, type }) => { - if (type === AppTokenType.RefreshToken) { - await this.appTokenRepository.update( - { id }, - { - revokedAt: new Date(), - }, - ); - } - }), - ); + if (token.revokedAt) { + const wasRevokedBeforeGracePeriod = + token.revokedAt.getTime() <= Date.now() - ms(reuseGracePeriod); - throw new AuthException( - 'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ); + if (wasRevokedBeforeGracePeriod) { + // Token was revoked long ago and is being reused -- suspicious. + // Revoke all user refresh tokens as a safety measure. + await Promise.all( + user.appTokens.map(async ({ id, type }) => { + if (type === AppTokenType.RefreshToken) { + await this.appTokenRepository.update( + { id }, + { + revokedAt: new Date(), + }, + ); + } + }), + ); + + throw new AuthException( + 'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + ); + } + + // Token was revoked recently (within grace period). This is expected + // when concurrent requests (e.g. two browser tabs) race to refresh + // at the same time. Allow it but don't reset the original revokedAt + // timestamp so the grace window stays anchored and cannot be extended. } return { diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts index 5360d51a79..2df3b76fca 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.spec.ts @@ -1,7 +1,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; @@ -110,7 +110,7 @@ describe('RenewTokenService', () => { mockRefreshToken, ); expect(appTokenRepository.update).toHaveBeenCalledWith( - { id: mockTokenId }, + { id: mockTokenId, revokedAt: IsNull() }, { revokedAt: expect.any(Date) }, ); expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith( diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts index 20b17522d1..d297b4ea88 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; import { @@ -47,10 +47,14 @@ export class RenewTokenService { impersonatedUserWorkspaceId, } = await this.refreshTokenService.verifyRefreshToken(token); - // Revoke old refresh token + // Revoke old refresh token only if not already revoked. + // If it was already revoked (concurrent race condition within grace + // period), we preserve the original revokedAt timestamp so the grace + // window stays anchored and cannot be extended by repeated reuse. await this.appTokenRepository.update( { id, + revokedAt: IsNull(), }, { revokedAt: new Date(), diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 3715f21dc3..5eaf7a9f94 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -277,12 +277,13 @@ export class ConfigVariables { @ConfigVariablesMetadata({ group: ConfigVariablesGroup.TOKENS_DURATION, - description: 'Cooldown period for refreshing tokens', + description: + 'Grace period allowing concurrent refresh token use (e.g. two tabs refreshing simultaneously). Reuse after this window triggers suspicious activity detection.', type: ConfigVariableType.STRING, }) @IsDuration() @IsOptional() - REFRESH_TOKEN_COOL_DOWN = '1m'; + REFRESH_TOKEN_REUSE_GRACE_PERIOD = '1m'; @ConfigVariablesMetadata({ group: ConfigVariablesGroup.TOKENS_DURATION,