diff --git a/backend/.env.example b/backend/.env.example index 09f99ca..56b83f4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,6 +19,10 @@ REDIS_HOST=localhost REDIS_PORT=6379 USE_REDIS_CACHE=true +# ─── Token Cleanup ────────────────────────────────────────────────────────────── +# Cron expression for the scheduled cleanup job (default: 3 AM daily). +REFRESH_TOKEN_CLEANUP_CRON="0 3 * * *" + # ─── CORS / Frontend ──────────────────────────────────────────────────────────── # Origin allowed by CORS (frontend URL). Must be a valid URI. ALLOWED_ORIGIN=http://localhost:5173 diff --git a/backend/Dockerfile b/backend/Dockerfile index 378706c..23dda20 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:14 +FROM node:20-slim # Create app directory WORKDIR /usr/src/app diff --git a/backend/package.json b/backend/package.json index 72d1c09..147cb41 100644 --- a/backend/package.json +++ b/backend/package.json @@ -51,6 +51,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "cookie-parser": "^1.4.7", + "cron": "^4.3.3", "dotenv": "^16.4.5", "figlet": "^1.8.0", "helmet": "^8.0.0", diff --git a/backend/src/migrations/1765038000000-AddTokenCleanupIndexes.ts b/backend/src/migrations/1765038000000-AddTokenCleanupIndexes.ts new file mode 100644 index 0000000..e07485e --- /dev/null +++ b/backend/src/migrations/1765038000000-AddTokenCleanupIndexes.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds indexes to support efficient token cleanup queries. + * + * The cleanup job deletes rows matching: + * refresh_tokens: revoked = true OR "expiresAt" < now + * password_resets: used = true OR "expiresAt" < now + * + * Partial indexes on the boolean flags keep index size small by only + * covering the rows that will actually be deleted. + */ +export class AddTokenCleanupIndexes1765038000000 implements MigrationInterface { + name = 'AddTokenCleanupIndexes1765038000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Partial index on revoked refresh tokens — covers the `revoked = true` + // predicate of the cleanup DELETE's OR condition, keeping the index small + // by only including rows that will eventually be deleted. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_refresh_tokens_revoked" + ON "refresh_tokens" ("revoked") + WHERE "revoked" = TRUE + `); + + // Range index for expiry-based cleanup of refresh tokens + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_refresh_tokens_expiresAt" + ON "refresh_tokens" ("expiresAt") + `); + + // Partial index on used password resets — covers the `used = true` + // predicate of the cleanup DELETE's OR condition, keeping the index small + // by only including rows that will eventually be deleted. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_password_resets_used" + ON "password_resets" ("used") + WHERE "used" = TRUE + `); + + // Range index for expiry-based cleanup of password resets + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_password_resets_expiresAt" + ON "password_resets" ("expiresAt") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_password_resets_expiresAt"`, + ); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_password_resets_used"`); + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_refresh_tokens_expiresAt"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_refresh_tokens_revoked"`, + ); + } +} diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index d20eb70..3100388 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -4,6 +4,7 @@ import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; +import { TokenCleanupService } from './token-cleanup.service'; import { LocalStrategy } from './local.strategy'; import { JwtStrategy } from './jwt.strategy'; import { UsersModule } from '../users/users.module'; @@ -26,7 +27,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; }), ], controllers: [AuthController], - providers: [AuthService, LocalStrategy, JwtStrategy], + providers: [AuthService, TokenCleanupService, LocalStrategy, JwtStrategy], exports: [AuthService], }) export class AuthModule {} diff --git a/backend/src/modules/auth/token-cleanup.service.spec.ts b/backend/src/modules/auth/token-cleanup.service.spec.ts new file mode 100644 index 0000000..a315c3c --- /dev/null +++ b/backend/src/modules/auth/token-cleanup.service.spec.ts @@ -0,0 +1,289 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TokenCleanupService } from './token-cleanup.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { RefreshToken } from './refresh-token.entity'; +import { PasswordReset } from './password-reset.entity'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { ConfigService } from '@nestjs/config'; + +// Helper that mirrors the safe JEST_WORKER_ID restore pattern used throughout +// this file: deletes the variable when the original value was undefined rather +// than assigning the string 'undefined' (Node coerces undefined to 'undefined' +// in process.env, which would leave the guard permanently set). +function restoreWorker(original: string | undefined): void { + if (original === undefined) { + delete process.env['JEST_WORKER_ID']; + } else { + process.env['JEST_WORKER_ID'] = original; + } +} + +describe('TokenCleanupService', () => { + let service: TokenCleanupService; + + const mockQueryBuilder = { + delete: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + execute: jest.fn(), + }; + + const mockRefreshTokenRepository = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }; + + const mockPasswordResetRepository = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }; + + const mockSchedulerRegistry = { + addCronJob: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TokenCleanupService, + { + provide: getRepositoryToken(RefreshToken), + useValue: mockRefreshTokenRepository, + }, + { + provide: getRepositoryToken(PasswordReset), + useValue: mockPasswordResetRepository, + }, + { + provide: SchedulerRegistry, + useValue: mockSchedulerRegistry, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + service = module.get(TokenCleanupService); + jest.clearAllMocks(); + }); + + describe('onApplicationBootstrap', () => { + it('should not register cron when NODE_ENV is test', () => { + mockConfigService.get.mockImplementation((key: string) => + key === 'NODE_ENV' ? 'test' : undefined, + ); + + service.onApplicationBootstrap(); + + expect(mockSchedulerRegistry.addCronJob).not.toHaveBeenCalled(); + }); + + it('should not register cron when JEST_WORKER_ID is set', () => { + const originalWorker = process.env['JEST_WORKER_ID']; + process.env['JEST_WORKER_ID'] = '1'; + mockConfigService.get.mockReturnValue('development'); + + service.onApplicationBootstrap(); + + expect(mockSchedulerRegistry.addCronJob).not.toHaveBeenCalled(); + restoreWorker(originalWorker); + }); + + it('should not register cron when schedulerRegistry is absent', () => { + // Simulate the @Optional() case: schedulerRegistry is undefined + const serviceWithoutRegistry = new TokenCleanupService( + mockRefreshTokenRepository as any, + mockPasswordResetRepository as any, + mockConfigService as any, + undefined, + ); + + const originalWorker = process.env['JEST_WORKER_ID']; + delete process.env['JEST_WORKER_ID']; + mockConfigService.get.mockImplementation((key: string) => + key === 'NODE_ENV' ? 'production' : undefined, + ); + + serviceWithoutRegistry.onApplicationBootstrap(); + + expect(mockSchedulerRegistry.addCronJob).not.toHaveBeenCalled(); + restoreWorker(originalWorker); + }); + + it('should register cron with default expression in non-test env', () => { + const originalWorker = process.env['JEST_WORKER_ID']; + delete process.env['JEST_WORKER_ID']; + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'production'; + return undefined; // REFRESH_TOKEN_CLEANUP_CRON not set → fallback + }); + + service.onApplicationBootstrap(); + + expect(mockSchedulerRegistry.addCronJob).toHaveBeenCalledWith( + 'tokenCleanup', + expect.any(Object), + ); + restoreWorker(originalWorker); + }); + + it('should register cron with custom expression from config', () => { + const originalWorker = process.env['JEST_WORKER_ID']; + delete process.env['JEST_WORKER_ID']; + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'production'; + if (key === 'REFRESH_TOKEN_CLEANUP_CRON') return '0 2 * * *'; + return undefined; + }); + + service.onApplicationBootstrap(); + + expect(mockSchedulerRegistry.addCronJob).toHaveBeenCalledWith( + 'tokenCleanup', + expect.any(Object), + ); + restoreWorker(originalWorker); + }); + + it('should fall back to default cron expression when config value is invalid', () => { + const originalWorker = process.env['JEST_WORKER_ID']; + delete process.env['JEST_WORKER_ID']; + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'production'; + if (key === 'REFRESH_TOKEN_CLEANUP_CRON') return 'not-a-valid-cron'; + return undefined; + }); + + // Should not throw, and should still register the job with the fallback + expect(() => service.onApplicationBootstrap()).not.toThrow(); + expect(mockSchedulerRegistry.addCronJob).toHaveBeenCalledWith( + 'tokenCleanup', + expect.any(Object), + ); + restoreWorker(originalWorker); + }); + + it('should treat blank REFRESH_TOKEN_CLEANUP_CRON as unset and use default', () => { + const originalWorker = process.env['JEST_WORKER_ID']; + delete process.env['JEST_WORKER_ID']; + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'NODE_ENV') return 'production'; + if (key === 'REFRESH_TOKEN_CLEANUP_CRON') return ' '; // blank/whitespace + return undefined; + }); + + service.onApplicationBootstrap(); + + expect(mockSchedulerRegistry.addCronJob).toHaveBeenCalledWith( + 'tokenCleanup', + expect.any(Object), + ); + restoreWorker(originalWorker); + }); + }); + + describe('cleanupExpiredTokens', () => { + it('should return early in test environment without touching the database', async () => { + // Explicitly set NODE_ENV to 'test' for a deterministic guard check + mockConfigService.get.mockImplementation((key: string) => + key === 'NODE_ENV' ? 'test' : undefined, + ); + + await service.cleanupExpiredTokens(); + + expect( + mockRefreshTokenRepository.createQueryBuilder, + ).not.toHaveBeenCalled(); + expect( + mockPasswordResetRepository.createQueryBuilder, + ).not.toHaveBeenCalled(); + }); + + it('should return early when JEST_WORKER_ID is set without touching the database', async () => { + const originalWorker = process.env['JEST_WORKER_ID']; + process.env['JEST_WORKER_ID'] = '1'; + mockConfigService.get.mockImplementation((key: string) => + key === 'NODE_ENV' ? 'development' : undefined, + ); + + await service.cleanupExpiredTokens(); + + expect( + mockRefreshTokenRepository.createQueryBuilder, + ).not.toHaveBeenCalled(); + expect( + mockPasswordResetRepository.createQueryBuilder, + ).not.toHaveBeenCalled(); + restoreWorker(originalWorker); + }); + + describe('in a non-test environment', () => { + let originalWorker: string | undefined; + + beforeEach(() => { + originalWorker = process.env['JEST_WORKER_ID']; + delete process.env['JEST_WORKER_ID']; + mockConfigService.get.mockImplementation((key: string) => + key === 'NODE_ENV' ? 'development' : undefined, + ); + mockQueryBuilder.execute + .mockResolvedValueOnce({ affected: 3 }) + .mockResolvedValueOnce({ affected: 1 }); + }); + + afterEach(() => { + restoreWorker(originalWorker); + }); + + it('should delete revoked and expired refresh tokens', async () => { + await service.cleanupExpiredTokens(); + + expect( + mockRefreshTokenRepository.createQueryBuilder, + ).toHaveBeenCalled(); + const [whereClause, params] = mockQueryBuilder.where.mock.calls[0]; + expect(whereClause).toContain('revoked'); + expect(whereClause).toContain('"expiresAt"'); + expect(params).toMatchObject({ revoked: true, now: expect.any(Date) }); + }); + + it('should delete used and expired password resets', async () => { + await service.cleanupExpiredTokens(); + + expect( + mockPasswordResetRepository.createQueryBuilder, + ).toHaveBeenCalled(); + const [whereClause, params] = mockQueryBuilder.where.mock.calls[1]; + expect(whereClause).toContain('used'); + expect(whereClause).toContain('"expiresAt"'); + expect(params).toMatchObject({ used: true, now: expect.any(Date) }); + }); + + it('should not throw when a query fails', async () => { + mockQueryBuilder.execute.mockReset(); + mockQueryBuilder.execute.mockRejectedValueOnce(new Error('DB failure')); + + await expect(service.cleanupExpiredTokens()).resolves.toBeUndefined(); + }); + + it('should still clean up password resets when refresh token cleanup fails', async () => { + mockQueryBuilder.execute.mockReset(); + mockQueryBuilder.execute + .mockRejectedValueOnce(new Error('refresh token DB failure')) + .mockResolvedValueOnce({ affected: 2 }); + + await expect(service.cleanupExpiredTokens()).resolves.toBeUndefined(); + // Both query builders should have been invoked despite the first failure + expect( + mockRefreshTokenRepository.createQueryBuilder, + ).toHaveBeenCalled(); + expect( + mockPasswordResetRepository.createQueryBuilder, + ).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/backend/src/modules/auth/token-cleanup.service.ts b/backend/src/modules/auth/token-cleanup.service.ts new file mode 100644 index 0000000..126deef --- /dev/null +++ b/backend/src/modules/auth/token-cleanup.service.ts @@ -0,0 +1,134 @@ +import { + Injectable, + Logger, + OnApplicationBootstrap, + Optional, +} from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { CronJob } from 'cron'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { RefreshToken } from './refresh-token.entity'; +import { PasswordReset } from './password-reset.entity'; + +@Injectable() +export class TokenCleanupService implements OnApplicationBootstrap { + private readonly logger = new Logger(TokenCleanupService.name); + + constructor( + @InjectRepository(RefreshToken) + private readonly refreshTokenRepository: Repository, + @InjectRepository(PasswordReset) + private readonly passwordResetRepository: Repository, + private readonly configService: ConfigService, + // Optional: ScheduleModule is intentionally excluded in test environments. + // @Optional() allows the service to be instantiated without it and + // onApplicationBootstrap() guards against a missing registry. + @Optional() private readonly schedulerRegistry?: SchedulerRegistry, + ) {} + + onApplicationBootstrap(): void { + if ( + this.configService.get('NODE_ENV') === 'test' || + process.env['JEST_WORKER_ID'] !== undefined + ) { + return; + } + + if (!this.schedulerRegistry) { + this.logger.warn( + 'SchedulerRegistry is not available — token cleanup cron will not run. ' + + 'Ensure ScheduleModule is imported in AppModule.', + ); + return; + } + + // Use ConfigService rather than @Cron() because decorator arguments are + // evaluated at class-definition time (before DI runs), so there is no way + // to inject ConfigService into a @Cron() expression. Reading from + // ConfigService here gives us the fully-loaded, validated config value. + // Use || so a blank/whitespace-only value is treated the same as unset. + const DEFAULT_CRON = '0 3 * * *'; + const rawExpression = this.configService + .get('REFRESH_TOKEN_CLEANUP_CRON') + ?.trim(); + const cronExpression = rawExpression || DEFAULT_CRON; + + let effectiveExpression = cronExpression; + let job: CronJob; + try { + job = new CronJob(cronExpression, () => { + void this.cleanupExpiredTokens(); + }); + } catch { + this.logger.warn( + `Invalid REFRESH_TOKEN_CLEANUP_CRON value "${cronExpression}", ` + + `falling back to default "${DEFAULT_CRON}"`, + ); + effectiveExpression = DEFAULT_CRON; + job = new CronJob(DEFAULT_CRON, () => { + void this.cleanupExpiredTokens(); + }); + } + + this.schedulerRegistry.addCronJob('tokenCleanup', job); + job.start(); + this.logger.log(`Token cleanup cron registered: ${effectiveExpression}`); + } + + async cleanupExpiredTokens(): Promise { + if ( + this.configService.get('NODE_ENV') === 'test' || + process.env['JEST_WORKER_ID'] !== undefined + ) { + return; + } + + const start = Date.now(); + this.logger.log('Starting expired/revoked token cleanup'); + const now = new Date(); + + // Each table is cleaned independently so a failure in one does not prevent + // the other from running — both errors are logged separately. + let refreshDeleted = 0; + try { + const { affected } = await this.refreshTokenRepository + .createQueryBuilder() + .delete() + .where('revoked = :revoked OR "expiresAt" < :now', { + revoked: true, + now, + }) + .execute(); + refreshDeleted = affected ?? 0; + } catch (error) { + this.logger.error( + 'Refresh token cleanup failed', + error instanceof Error ? error.stack : String(error), + ); + } + + let resetDeleted = 0; + try { + const { affected } = await this.passwordResetRepository + .createQueryBuilder() + .delete() + .where('used = :used OR "expiresAt" < :now', { used: true, now }) + .execute(); + resetDeleted = affected ?? 0; + } catch (error) { + this.logger.error( + 'Password reset cleanup failed', + error instanceof Error ? error.stack : String(error), + ); + } + + const duration = Date.now() - start; + this.logger.log( + `Token cleanup complete in ${duration}ms — ` + + `deleted ${refreshDeleted} refresh token(s), ` + + `${resetDeleted} password reset(s)`, + ); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c4aae6..e23cafe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: cookie-parser: specifier: ^1.4.7 version: 1.4.7 + cron: + specifier: ^4.3.3 + version: 4.3.3 dotenv: specifier: ^16.4.5 version: 16.6.1