From 7bdd183f922b456128c5c5aa9b223749deb3f999 Mon Sep 17 00:00:00 2001 From: Demian Date: Sat, 11 Apr 2026 01:15:17 -0400 Subject: [PATCH 1/2] feat: add scheduled refresh token and password reset cleanup job Add TokenCleanupService to the auth module with a @Cron job that runs daily at 3am (configurable via REFRESH_TOKEN_CLEANUP_CRON) and deletes all refresh_tokens rows where revoked=true or expires_at < now, and all password_resets rows where used=true or expires_at < now. - Job skips early when NODE_ENV=test - Logs row count and duration on success, error stack on failure - Does not rethrow on failure so job errors cannot crash the process - ScheduleModule is already conditionally excluded in test env (AppModule) Closes #98 --- backend/.env.example | 3 +++ backend/src/modules/auth/auth.module.ts | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/backend/.env.example b/backend/.env.example index 9e03ac1..dd893dc 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,6 +17,9 @@ USE_REDIS_CACHE=true PORT=3001 APP_NAME=STATION BACKEND +# Token Cleanup Cron (default: 3am daily) +REFRESH_TOKEN_CLEANUP_CRON="0 3 * * *" + # UEX Sync Configuration UEX_SYNC_ENABLED=true UEX_CATEGORIES_SYNC_ENABLED=true diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index fb2778c..cc82483 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 { RefreshTokenStrategy } from './refresh-token.strategy'; @@ -27,7 +28,13 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; }), ], controllers: [AuthController], - providers: [AuthService, LocalStrategy, JwtStrategy, RefreshTokenStrategy], + providers: [ + AuthService, + TokenCleanupService, + LocalStrategy, + JwtStrategy, + RefreshTokenStrategy, + ], exports: [AuthService], }) export class AuthModule {} From 0507031a27240e5abd29189bdde8d1a2c42156e0 Mon Sep 17 00:00:00 2001 From: Demian Date: Sat, 11 Apr 2026 01:15:57 -0400 Subject: [PATCH 2/2] feat: add TokenCleanupService implementation Co-authored-with: ISSUE-98 --- .../src/modules/auth/token-cleanup.service.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 backend/src/modules/auth/token-cleanup.service.ts 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..d1a0748 --- /dev/null +++ b/backend/src/modules/auth/token-cleanup.service.ts @@ -0,0 +1,64 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +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 { + private readonly logger = new Logger(TokenCleanupService.name); + + constructor( + @InjectRepository(RefreshToken) + private readonly refreshTokenRepository: Repository, + @InjectRepository(PasswordReset) + private readonly passwordResetRepository: Repository, + private readonly configService: ConfigService, + ) {} + + @Cron( + // Default: 3am daily — override with REFRESH_TOKEN_CLEANUP_CRON env var + process.env['REFRESH_TOKEN_CLEANUP_CRON'] ?? '0 3 * * *', + ) + async cleanupExpiredTokens(): Promise { + if (process.env['NODE_ENV'] === 'test') { + return; + } + + const start = Date.now(); + this.logger.log('Starting expired/revoked token cleanup'); + + try { + const now = new Date(); + + const { affected: refreshDeleted } = await this.refreshTokenRepository + .createQueryBuilder() + .delete() + .where('revoked = :revoked OR expires_at < :now', { + revoked: true, + now, + }) + .execute(); + + const { affected: resetDeleted } = await this.passwordResetRepository + .createQueryBuilder() + .delete() + .where('used = :used OR expires_at < :now', { used: true, now }) + .execute(); + + const duration = Date.now() - start; + this.logger.log( + `Token cleanup complete in ${duration}ms — ` + + `deleted ${refreshDeleted ?? 0} refresh token(s), ` + + `${resetDeleted ?? 0} password reset(s)`, + ); + } catch (error) { + this.logger.error( + 'Token cleanup job failed', + error instanceof Error ? error.stack : String(error), + ); + } + } +}