From b0dfb517f24b72fdd37f8be7bc11a6ee7c5ecde0 Mon Sep 17 00:00:00 2001 From: udaycodespace Date: Tue, 16 Jun 2026 13:20:28 +0530 Subject: [PATCH] feat: implement automated refresh token cleanup and schema optimization --- .env.example | 5 +- apps/backend/prisma/schema.prisma | 2 + .../src/__tests__/refreshTokenCleanup.test.ts | 319 ++++++++++++++++++ apps/backend/src/app.ts | 197 ++++++----- .../src/plugins/refreshTokenCleanup.ts | 57 ++++ .../services/refreshTokenCleanupService.ts | 35 ++ 6 files changed, 528 insertions(+), 87 deletions(-) create mode 100644 apps/backend/src/__tests__/refreshTokenCleanup.test.ts create mode 100644 apps/backend/src/plugins/refreshTokenCleanup.ts create mode 100644 apps/backend/src/services/refreshTokenCleanupService.ts diff --git a/.env.example b/.env.example index ad7ce014..3e8f83b0 100644 --- a/.env.example +++ b/.env.example @@ -32,4 +32,7 @@ MOBILE_REDIRECT_URI=devcard://oauth/callback # ─── Server ─── PORT=3000 -NODE_ENV=development \ No newline at end of file +NODE_ENV=development + +# ─── Refresh Token Cleanup ─── +REFRESH_TOKEN_CLEANUP_INTERVAL_MS=86400000 \ No newline at end of file diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 38fb91fe..c296bcea 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -77,6 +77,8 @@ model RefreshToken { @@index([userId]) @@index([family]) + @@index([expiresAt]) + @@index([revokedAt]) @@map("refresh_tokens") } diff --git a/apps/backend/src/__tests__/refreshTokenCleanup.test.ts b/apps/backend/src/__tests__/refreshTokenCleanup.test.ts new file mode 100644 index 00000000..d66c36a5 --- /dev/null +++ b/apps/backend/src/__tests__/refreshTokenCleanup.test.ts @@ -0,0 +1,319 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { refreshTokenCleanupPlugin } from "../plugins/refreshTokenCleanup.js"; +import { cleanupExpiredAndRevokedTokens } from "../services/refreshTokenCleanupService.js"; +import * as service from "../services/refreshTokenCleanupService.js"; + +import type { PrismaClient } from "@prisma/client"; + +describe("refreshTokenCleanupService", () => { + const mockPrisma = { + refreshToken: { + deleteMany: vi.fn(), + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("active token survives (neither expired nor revoked are deleted)", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 }); + + const result = await cleanupExpiredAndRevokedTokens( + mockPrisma as unknown as PrismaClient, + ); + + expect(mockPrisma.refreshToken.deleteMany).toHaveBeenCalledTimes(1); + const callArgs = mockPrisma.refreshToken.deleteMany.mock.calls[0][0]; + + // Explicitly verify the query structure: + // It must delete ONLY: revokedAt is not null OR expiresAt has passed (expiresAt < now) + expect(callArgs?.where?.OR).toBeDefined(); + expect(callArgs.where.OR).toHaveLength(2); + expect(callArgs.where.OR).toContainEqual({ revokedAt: { not: null } }); + expect(callArgs.where.OR[1].expiresAt.lt).toBeInstanceOf(Date); + + expect(result.deletedCount).toBe(0); + }); + + it("expired token deleted", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 1 }); + + await cleanupExpiredAndRevokedTokens(mockPrisma as unknown as PrismaClient); + + const callArgs = mockPrisma.refreshToken.deleteMany.mock.calls[0][0]; + + expect(callArgs.where.OR).toContainEqual({ + expiresAt: { + lt: expect.any(Date), + }, + }); + }); + + it("revoked token deleted", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 1 }); + + await cleanupExpiredAndRevokedTokens(mockPrisma as unknown as PrismaClient); + + const callArgs = mockPrisma.refreshToken.deleteMany.mock.calls[0][0]; + + expect(callArgs.where.OR).toContainEqual({ + revokedAt: { + not: null, + }, + }); + }); + + it("mixed dataset query contains both cleanup conditions", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 5 }); + + await cleanupExpiredAndRevokedTokens(mockPrisma as unknown as PrismaClient); + + const callArgs = mockPrisma.refreshToken.deleteMany.mock.calls[0][0]; + + expect(callArgs.where.OR).toHaveLength(2); + }); + + it("returns the exact count of deleted tokens reported by the database", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 15 }); + const result = await cleanupExpiredAndRevokedTokens( + mockPrisma as unknown as PrismaClient, + ); + expect(result.deletedCount).toBe(15); + }); + + it("empty dataset (table is empty, deleteMany returns 0)", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 }); + const result = await cleanupExpiredAndRevokedTokens( + mockPrisma as unknown as PrismaClient, + ); + expect(result.deletedCount).toBe(0); + }); + + it("service error handling is propagated correctly", async () => { + mockPrisma.refreshToken.deleteMany.mockRejectedValue( + new Error("Database query timeout"), + ); + await expect( + cleanupExpiredAndRevokedTokens(mockPrisma as unknown as PrismaClient), + ).rejects.toThrow("Database query timeout"); + }); + + it("service is idempotent on multiple executions", async () => { + mockPrisma.refreshToken.deleteMany.mockResolvedValue({ count: 0 }); + const run1 = await cleanupExpiredAndRevokedTokens( + mockPrisma as unknown as PrismaClient, + ); + const run2 = await cleanupExpiredAndRevokedTokens( + mockPrisma as unknown as PrismaClient, + ); + expect(run1.deletedCount).toBe(0); + expect(run2.deletedCount).toBe(0); + expect(mockPrisma.refreshToken.deleteMany).toHaveBeenCalledTimes(2); + }); +}); + +describe("refreshTokenCleanupPlugin", () => { + let app: FastifyInstance | null = null; + const mockPrisma = { + refreshToken: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + app = Fastify({ logger: { level: "error" } }); + app.decorate("prisma", mockPrisma as unknown as PrismaClient); + }); + + afterEach(async () => { + vi.useRealTimers(); + if (app) { + await app.close(); + app = null; + } + delete process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS; + }); + + it("starts cleanup on register/startup and schedules interval", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 3, + durationMs: 15, + }); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "60000"; // 1 minute + + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + // Verification 1: startup cleanup run + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Verification 2: interval execution + await vi.advanceTimersByTimeAsync(60000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(60000); + expect(cleanupSpy).toHaveBeenCalledTimes(3); + }); + + it("invalid interval fallback (undefined)", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 0, + durationMs: 1, + }); + + delete process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS; + + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + expect(cleanupSpy).toHaveBeenCalledTimes(1); // startup + + // Should not run at 1 minute + await vi.advanceTimersByTimeAsync(60000); + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Should run at 24 hours (86400000ms) + await vi.advanceTimersByTimeAsync(86400000 - 60000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + }); + + it("invalid interval fallback (NaN)", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 0, + durationMs: 1, + }); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "not-a-number"; + + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + // Startup run + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Should fallback to 24 hours + await vi.advanceTimersByTimeAsync(86400000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + }); + + it("invalid interval fallback (<= 0)", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 0, + durationMs: 1, + }); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "0"; + + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + // Startup run + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Should fallback to 24 hours + await vi.advanceTimersByTimeAsync(86400000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + }); + + it("invalid interval fallback (Infinity)", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 0, + durationMs: 1, + }); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "Infinity"; + + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + // Startup run + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Should fallback to 24 hours + await vi.advanceTimersByTimeAsync(86400000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + }); + + it("startup cleanup failure logs error but does not crash app or stop scheduler", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockRejectedValue(new Error("Connection failure")); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "5000"; + + await app!.register(refreshTokenCleanupPlugin); + + // app!.ready() should resolve successfully without throwing + await expect(app!.ready()).resolves.toBeDefined(); + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Scheduler should still function + cleanupSpy.mockResolvedValue({ deletedCount: 0, durationMs: 1 }); + await vi.advanceTimersByTimeAsync(5000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + }); + + it("scheduled cleanup failure logs error but does not crash process", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 0, + durationMs: 1, + }); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "5000"; + + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + // Fail during scheduled run + cleanupSpy.mockRejectedValue(new Error("Transaction deadlock")); + await vi.advanceTimersByTimeAsync(5000); + expect(cleanupSpy).toHaveBeenCalledTimes(2); + + // Next run works again + cleanupSpy.mockResolvedValue({ deletedCount: 1, durationMs: 1 }); + await vi.advanceTimersByTimeAsync(5000); + expect(cleanupSpy).toHaveBeenCalledTimes(3); + }); + + it("shutdown clears interval and avoids timer leaks", async () => { + const cleanupSpy = vi + .spyOn(service, "cleanupExpiredAndRevokedTokens") + .mockResolvedValue({ + deletedCount: 0, + durationMs: 1, + }); + + process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS = "1000"; + await app!.register(refreshTokenCleanupPlugin); + await app!.ready(); + + expect(cleanupSpy).toHaveBeenCalledTimes(1); + + await app!.close(); + app = null; + + // Advance timer: should not be called again because onClose hook cleared it + await vi.advanceTimersByTimeAsync(5000); + expect(cleanupSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 44842088..68291121 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -1,34 +1,39 @@ -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import cookie from '@fastify/cookie'; -import cors from '@fastify/cors'; -import helmet from '@fastify/helmet'; -import jwt from '@fastify/jwt'; -import multipart from '@fastify/multipart'; -import rateLimit from '@fastify/rate-limit'; -import Fastify, {type FastifyInstance, type FastifyReply, type FastifyRequest} from 'fastify'; - -import { prismaPlugin } from './plugins/prisma.js'; -import { redisPlugin } from './plugins/redis.js'; -import { analyticsRoutes } from './routes/analytics.js'; -import { authRoutes } from './routes/auth.js'; -import { cardRoutes } from './routes/cards.js'; -import { connectRoutes } from './routes/connect.js'; -import { eventRoutes } from './routes/event.js'; -import { followRoutes } from './routes/follow.js'; -import { nfcRoutes } from './routes/nfc.js'; -import { profileRoutes } from './routes/profiles.js'; -import { publicRoutes } from './routes/public.js'; -import { teamRoutes } from './routes/team.js'; -import { extractRawJwt, blocklistKey } from './utils/jwt.js'; -import { validateEnv } from './utils/validateEnv.js'; - -import type { AuthenticatedUser } from './types/fastify.js'; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import cookie from "@fastify/cookie"; +import cors from "@fastify/cors"; +import helmet from "@fastify/helmet"; +import jwt from "@fastify/jwt"; +import multipart from "@fastify/multipart"; +import rateLimit from "@fastify/rate-limit"; +import Fastify, { + type FastifyInstance, + type FastifyReply, + type FastifyRequest, +} from "fastify"; + +import { prismaPlugin } from "./plugins/prisma.js"; +import { redisPlugin } from "./plugins/redis.js"; +import { refreshTokenCleanupPlugin } from "./plugins/refreshTokenCleanup.js"; +import { analyticsRoutes } from "./routes/analytics.js"; +import { authRoutes } from "./routes/auth.js"; +import { cardRoutes } from "./routes/cards.js"; +import { connectRoutes } from "./routes/connect.js"; +import { eventRoutes } from "./routes/event.js"; +import { followRoutes } from "./routes/follow.js"; +import { nfcRoutes } from "./routes/nfc.js"; +import { profileRoutes } from "./routes/profiles.js"; +import { publicRoutes } from "./routes/public.js"; +import { teamRoutes } from "./routes/team.js"; +import { extractRawJwt, blocklistKey } from "./utils/jwt.js"; +import { validateEnv } from "./utils/validateEnv.js"; + +import type { AuthenticatedUser } from "./types/fastify.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -export async function buildApp():Promise { +export async function buildApp(): Promise { // Validate all required secrets before registering any plugin. // If validation fails the process exits here — no partially-initialised // auth state can exist because Fastify is not yet instantiated. @@ -36,23 +41,26 @@ export async function buildApp():Promise { const app = Fastify({ logger: { - level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + level: process.env.NODE_ENV === "production" ? "info" : "debug", transport: - process.env.NODE_ENV !== 'production' - ? { target: 'pino-pretty', options: { colorize: true } } + process.env.NODE_ENV !== "production" + ? { target: "pino-pretty", options: { colorize: true } } : undefined, }, }); // Log method + path for every incoming request. - app.addHook('onRequest', (request, _reply, done) => { - app.log.info({ method: request.method, url: request.url }, 'incoming request'); + app.addHook("onRequest", (request, _reply, done) => { + app.log.info( + { method: request.method, url: request.url }, + "incoming request", + ); done(); }); // ─── Core Plugins ─── await app.register(cors, { - origin: process.env.PUBLIC_APP_URL || 'http://localhost:5173', + origin: process.env.PUBLIC_APP_URL || "http://localhost:5173", credentials: true, }); @@ -61,13 +69,18 @@ export async function buildApp():Promise { directives: { defaultSrc: ["'self'"], baseUri: ["'self'"], - fontSrc: ["'self'", 'https:', 'data:', 'https://fonts.gstatic.com'], + fontSrc: ["'self'", "https:", "data:", "https://fonts.gstatic.com"], frameAncestors: ["'self'"], - imgSrc: ["'self'", 'data:', 'https:'], + imgSrc: ["'self'", "data:", "https:"], objectSrc: ["'none'"], scriptSrc: ["'self'"], scriptSrcAttr: ["'none'"], - styleSrc: ["'self'", 'https:', "'unsafe-inline'", 'https://fonts.googleapis.com'], + styleSrc: [ + "'self'", + "https:", + "'unsafe-inline'", + "https://fonts.googleapis.com", + ], upgradeInsecureRequests: [], }, }, @@ -82,81 +95,93 @@ export async function buildApp():Promise { secret: process.env.JWT_SECRET!, cookie: { // Matches the cookie name set in the OAuth callback handlers. - cookieName: 'token', + cookieName: "token", signed: false, }, }); await app.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } }); // 5MB await app.register(rateLimit, { max: 100, - timeWindow: '1 minute', + timeWindow: "1 minute", }); -// Files must be served through authenticated route handlers -// with ownership validation. + // Files must be served through authenticated route handlers + // with ownership validation. // ─── Database & Cache Plugins ─── - if (process.env.NODE_ENV !== 'test') { - await app.register(prismaPlugin); //change -} - if (process.env.NODE_ENV !== 'test') { - await app.register(redisPlugin); -} + if (process.env.NODE_ENV !== "test") { + await app.register(prismaPlugin); //change + } + if (process.env.NODE_ENV !== "test") { + await app.register(redisPlugin); + } + if (process.env.NODE_ENV !== "test") { + await app.register(refreshTokenCleanupPlugin); + } // ─── Auth Decorator ─── // Checks the Redis blocklist before calling jwtVerify so that a logged-out // token is rejected immediately even if it has not yet expired. // The blocklist check is skipped when Redis is not registered (test env). - app.decorate('authenticate', async function (request: FastifyRequest, reply: FastifyReply) { - try { - if (app.hasDecorator('redis')) { - const raw = extractRawJwt(request); - if (raw) { - try { - const revoked = await app.redis.exists(blocklistKey(raw)); - if (revoked) { - return reply.status(401).send({ error: 'Token has been revoked' }); + app.decorate( + "authenticate", + async function (request: FastifyRequest, reply: FastifyReply) { + try { + if (app.hasDecorator("redis")) { + const raw = extractRawJwt(request); + if (raw) { + try { + const revoked = await app.redis.exists(blocklistKey(raw)); + if (revoked) { + return reply + .status(401) + .send({ error: "Token has been revoked" }); + } + } catch (redisErr) { + // Redis is unavailable — fail open to avoid an outage on every + // authenticated request. The JWT expiry is still the safety net. + app.log.warn( + { err: redisErr }, + "Redis blocklist check failed — proceeding with JWT verification", + ); } - } catch (redisErr) { - // Redis is unavailable — fail open to avoid an outage on every - // authenticated request. The JWT expiry is still the safety net. - app.log.warn({ err: redisErr }, 'Redis blocklist check failed — proceeding with JWT verification'); } } + // Assign verified payload to request.user (upstream addition). + const payload = await request.jwtVerify(); + if (payload) { + request.user = payload; + } + } catch (_err) { + return reply.status(401).send({ error: "Unauthorized" }); } - // Assign verified payload to request.user (upstream addition). - const payload = await request.jwtVerify(); - if (payload) { request.user = payload; } - } catch (_err) { - return reply.status(401).send({ error: 'Unauthorized' }); - } - }); + }, + ); // ─── Routes ─── - await app.register(authRoutes, { prefix: '/auth' }); - await app.register(profileRoutes, { prefix: '/api/profiles' }); - await app.register(cardRoutes, { prefix: '/api/cards' }); + await app.register(authRoutes, { prefix: "/auth" }); + await app.register(profileRoutes, { prefix: "/api/profiles" }); + await app.register(cardRoutes, { prefix: "/api/cards" }); // Public routes: standardise on `/api/u` (remove duplicate `/api/public`). - await app.register(publicRoutes, { prefix: '/api/u' }); - await app.register(followRoutes, { prefix: '/api/follow' }); - await app.register(connectRoutes, { prefix: '/api/connect' }); - await app.register(analyticsRoutes, { prefix: '/api/analytics' }); - await app.register(nfcRoutes, { prefix: '/api/nfc' }); - await app.register(eventRoutes, {prefix: '/api/events'}) - await app.register(teamRoutes, {prefix: '/api/teams'}) - + await app.register(publicRoutes, { prefix: "/api/u" }); + await app.register(followRoutes, { prefix: "/api/follow" }); + await app.register(connectRoutes, { prefix: "/api/connect" }); + await app.register(analyticsRoutes, { prefix: "/api/analytics" }); + await app.register(nfcRoutes, { prefix: "/api/nfc" }); + await app.register(eventRoutes, { prefix: "/api/events" }); + await app.register(teamRoutes, { prefix: "/api/teams" }); // ─── Health Check ─── -type HealthResponse = { - status: 'ok'; -}; + type HealthResponse = { + status: "ok"; + }; -app.get('/health', async (): Promise => { - return { status: 'ok' }; -}); + app.get("/health", async (): Promise => { + return { status: "ok" }; + }); // Centralized error handler: log and return a consistent 500 shape for unhandled errors. app.setErrorHandler((error, request, reply) => { - app.log.error({ err: error }, 'Unhandled error'); + app.log.error({ err: error }, "Unhandled error"); // Also print to console to aid test diagnostics when logger is disabled. // This helps surface stack traces in CI/test runs. // eslint-disable-next-line no-console @@ -166,7 +191,7 @@ app.get('/health', async (): Promise => { return; } // Keep response shape consistent across the API. - reply.status(500).send({ error: 'Internal server error' }); + reply.status(500).send({ error: "Internal server error" }); }); return app; } diff --git a/apps/backend/src/plugins/refreshTokenCleanup.ts b/apps/backend/src/plugins/refreshTokenCleanup.ts new file mode 100644 index 00000000..936d1eb7 --- /dev/null +++ b/apps/backend/src/plugins/refreshTokenCleanup.ts @@ -0,0 +1,57 @@ +import fp from 'fastify-plugin'; + +import { cleanupExpiredAndRevokedTokens } from '../services/refreshTokenCleanupService.js'; + +import type { FastifyInstance } from 'fastify'; + +export const refreshTokenCleanupPlugin = fp(async (app: FastifyInstance) => { + // Read environment variable for interval configuration + const intervalEnv = process.env.REFRESH_TOKEN_CLEANUP_INTERVAL_MS; + const defaultInterval = 86_400_000; // 24 hours in milliseconds + let intervalMs = defaultInterval; + + if (intervalEnv !== undefined) { + const parsed = Number(intervalEnv); + + if (Number.isFinite(parsed) && parsed > 0) { + intervalMs = parsed; + } else { + app.log.warn( + `Invalid REFRESH_TOKEN_CLEANUP_INTERVAL_MS value: "${intervalEnv}". Falling back to default: ${defaultInterval}ms` + ); + } + } + + // Execution function with try/catch and logging + const runCleanup = async (): Promise => { + app.log.info('Starting automated refresh token cleanup...'); + try { + const result = await cleanupExpiredAndRevokedTokens(app.prisma); + app.log.info( + { + deletedCount: result.deletedCount, + durationMs: result.durationMs, + }, + 'Refresh token cleanup completed' + ); + } catch (error) { + app.log.error({ err: error }, 'Automated refresh token cleanup failed'); + } + }; + + // 1. Startup cleanup attempt + await runCleanup(); + + // 2. Scheduled cleanup interval setup + app.log.info(`Scheduling automated refresh token cleanup every ${intervalMs}ms`); + const intervalId = setInterval(() => { + // Run cleanup asynchronously + void runCleanup(); + }, intervalMs); + + // 3. Graceful shutdown to avoid timer leaks + app.addHook('onClose', async () => { + clearInterval(intervalId); + app.log.info('Automated refresh token cleanup scheduler stopped'); + }); +}); diff --git a/apps/backend/src/services/refreshTokenCleanupService.ts b/apps/backend/src/services/refreshTokenCleanupService.ts new file mode 100644 index 00000000..e01130c2 --- /dev/null +++ b/apps/backend/src/services/refreshTokenCleanupService.ts @@ -0,0 +1,35 @@ +import type { PrismaClient } from '@prisma/client'; + +export interface CleanupResult { + deletedCount: number; + durationMs: number; +} + +/** + * Clean up expired and revoked refresh tokens from the database. + * Deletes where revokedAt is not null OR expiresAt has passed. + * Active tokens (revokedAt is null AND expiresAt is in the future) are not deleted. + */ +export async function cleanupExpiredAndRevokedTokens( + prisma: PrismaClient +): Promise { + const startTime = Date.now(); + const now = new Date(); + + // Perform deleteMany directly to avoid pre-fetching rows + const result = await prisma.refreshToken.deleteMany({ + where: { + OR: [ + { revokedAt: { not: null } }, + { expiresAt: { lt: now } }, + ], + }, + }); + + const durationMs = Date.now() - startTime; + + return { + deletedCount: result.count, + durationMs, + }; +}