Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 4ef1c303f4 fix: batch-revoke app tokens instead of N+1 individual updates
https://sonarly.com/issue/3877?type=bug

When suspicious refresh token reuse is detected, the server revokes all user tokens one-by-one (571 individual UPDATE queries in this case), causing ~9s of cumulative DB time and ~1s wall-clock latency per request before the user is force-logged out.

Fix: ## Changes

**`refresh-token.service.ts`** — 3 edits:

1. **Added `IsNull` import** from `typeorm` (line 6).
2. **Removed `relations: ['appTokens']`** from the `userRepository.findOne()` call (line 72). This relation was only needed for the N+1 loop and is not used by callers — `renew-token.service.ts` only accesses `user.id`.
3. **Replaced the N+1 `Promise.all` loop** (lines 89-100) with a single bulk `appTokenRepository.update()` call that uses `userId`, `type: RefreshToken`, and `revokedAt: IsNull()` as criteria. This collapses ~571 individual UPDATE queries into one SQL statement:
   ```sql
   UPDATE core."appToken"
   SET "revokedAt" = $1, "updatedAt" = CURRENT_TIMESTAMP
   WHERE "userId" = $2 AND "type" = 'REFRESH_TOKEN' AND "revokedAt" IS NULL
   ```

**`refresh-token.service.spec.ts`** — Added test:

Added a test case `'revokes all refresh tokens with a single bulk update on suspicious reuse'` that:
- Sets up a token revoked 2 minutes ago (outside the 1-minute grace period)
- Verifies `appTokenRepository.update` is called exactly once (bulk, not N+1)
- Verifies the update criteria match `{ userId, type: RefreshToken, revokedAt: IsNull() }`
- Verifies the method throws `AuthException`
2026-03-16 20:12:57 +00:00
2 changed files with 54 additions and 14 deletions
@@ -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,
@@ -213,6 +213,51 @@ describe('RefreshTokenService', () => {
expect(out.impersonatedUserWorkspaceId).toBe('uw-orig');
});
it('revokes all refresh tokens with a single bulk update on suspicious reuse', async () => {
const userId = 'user-id';
const tokenId = 'token-id';
(jwtWrapperService.verifyJwtToken as jest.Mock).mockResolvedValue(
undefined,
);
(jwtWrapperService.decode as jest.Mock).mockReturnValue({
sub: userId,
jti: tokenId,
type: JwtTokenTypeEnum.REFRESH,
});
(twentyConfigService.get as jest.Mock).mockReturnValue('1m');
const revokedLongAgo = new Date(Date.now() - 120_000);
const token = {
id: tokenId,
type: AppTokenType.RefreshToken,
revokedAt: revokedLongAgo,
} as AppTokenEntity;
jest.spyOn(appTokenRepository, 'findOneBy').mockResolvedValue(token);
jest
.spyOn(userRepository, 'findOne')
.mockResolvedValue({ id: userId } as UserEntity);
const updateSpy = jest
.spyOn(appTokenRepository, 'update')
.mockResolvedValue({} as any);
await expect(service.verifyRefreshToken('rtok')).rejects.toThrow(
AuthException,
);
expect(updateSpy).toHaveBeenCalledTimes(1);
expect(updateSpy).toHaveBeenCalledWith(
{
userId,
type: AppTokenType.RefreshToken,
revokedAt: IsNull(),
},
{ revokedAt: expect.any(Date) },
);
});
it('throws on malformed refresh token', async () => {
(jwtWrapperService.verifyJwtToken as jest.Mock).mockResolvedValue(
undefined,
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { Repository } from 'typeorm';
import { IsNull, Repository } from 'typeorm';
import {
AppTokenEntity,
@@ -69,7 +69,6 @@ export class RefreshTokenService {
const user = await this.userRepository.findOne({
where: { id: jwtPayload.sub },
relations: ['appTokens'],
});
if (!user) {
@@ -86,17 +85,13 @@ export class RefreshTokenService {
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(),
},
);
}
}),
await this.appTokenRepository.update(
{
userId: user.id,
type: AppTokenType.RefreshToken,
revokedAt: IsNull(),
},
{ revokedAt: new Date() },
);
throw new AuthException(