From b0dfb517f24b72fdd37f8be7bc11a6ee7c5ecde0 Mon Sep 17 00:00:00 2001 From: udaycodespace Date: Tue, 16 Jun 2026 13:20:28 +0530 Subject: [PATCH 1/4] 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, + }; +} From 2cfd046db732f31c9ff5802d0053f41b3bd950aa Mon Sep 17 00:00:00 2001 From: udaycodespace Date: Wed, 17 Jun 2026 12:28:29 +0530 Subject: [PATCH 2/4] fix: address review feedback for refresh token cleanup --- apps/backend/src/app.ts | 206 ++++++++---------- .../src/plugins/refreshTokenCleanup.ts | 2 +- 2 files changed, 94 insertions(+), 114 deletions(-) diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 68291121..2b15545d 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -1,39 +1,36 @@ -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"; +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. @@ -41,26 +38,23 @@ 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, }); @@ -69,18 +63,13 @@ 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: [], }, }, @@ -95,93 +84,84 @@ 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. - - // ─── 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(refreshTokenCleanupPlugin); - } +// Files must be served through authenticated route handlers +// with ownership validation. + +// ─── Database & Cache Plugins ─── +if (process.env.NODE_ENV !== 'test') { + await app.register(prismaPlugin); +} + +if (process.env.NODE_ENV !== 'test') { + await app.register(redisPlugin); + 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" }); - } - } 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", - ); + 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'); } } - // 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 @@ -191,7 +171,7 @@ export async function buildApp(): 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 index 936d1eb7..a198c7de 100644 --- a/apps/backend/src/plugins/refreshTokenCleanup.ts +++ b/apps/backend/src/plugins/refreshTokenCleanup.ts @@ -40,7 +40,7 @@ export const refreshTokenCleanupPlugin = fp(async (app: FastifyInstance) => { }; // 1. Startup cleanup attempt - await runCleanup(); + void runCleanup(); // 2. Scheduled cleanup interval setup app.log.info(`Scheduling automated refresh token cleanup every ${intervalMs}ms`); From 506ed521b9fa11b863db7084f0ff62ddc9d29816 Mon Sep 17 00:00:00 2001 From: udaycodespace Date: Wed, 17 Jun 2026 13:21:45 +0530 Subject: [PATCH 3/4] fix: add prisma migration for refresh token cleanup indexes --- .../migration.sql | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 apps/backend/prisma/migrations/20260617074743_refresh_token_cleanup_indexes/migration.sql diff --git a/apps/backend/prisma/migrations/20260617074743_refresh_token_cleanup_indexes/migration.sql b/apps/backend/prisma/migrations/20260617074743_refresh_token_cleanup_indexes/migration.sql new file mode 100644 index 00000000..94fcab49 --- /dev/null +++ b/apps/backend/prisma/migrations/20260617074743_refresh_token_cleanup_indexes/migration.sql @@ -0,0 +1,168 @@ +/* + Warnings: + + - You are about to drop the column `provider` on the `users` table. All the data in the column will be lost. + - You are about to drop the column `provider_id` on the `users` table. All the data in the column will be lost. + - A unique constraint covering the columns `[phone_number]` on the table `users` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('SUPERADMIN', 'ADMIN', 'USER'); + +-- CreateEnum +CREATE TYPE "TeamRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER'); + +-- DropIndex +DROP INDEX "users_provider_provider_id_key"; + +-- AlterTable +ALTER TABLE "users" DROP COLUMN "provider", +DROP COLUMN "provider_id", +ADD COLUMN "authRole" "Role" NOT NULL DEFAULT 'USER', +ADD COLUMN "email_verified" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "last_sign_in_at" TIMESTAMP(3), +ADD COLUMN "phone_number" TEXT; + +-- CreateTable +CREATE TABLE "user_identities" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "provider_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_identities_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "refresh_tokens" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "family" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "revoked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_agent" TEXT, + "ip" TEXT, + + CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Event" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "location" TEXT NOT NULL, + "description" TEXT, + "organizerId" TEXT NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3) NOT NULL, + "isPublic" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Event_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EventAttendee" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "joinedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EventAttendee_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "teams" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "avatarUrl" TEXT, + "ownerId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "teams_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "team_members" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "TeamRole" NOT NULL, + "joinedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "team_members_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "user_identities_user_id_idx" ON "user_identities"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_identities_provider_provider_id_key" ON "user_identities"("provider", "provider_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_family_idx" ON "refresh_tokens"("family"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_expires_at_idx" ON "refresh_tokens"("expires_at"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_revoked_at_idx" ON "refresh_tokens"("revoked_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "Event_slug_key" ON "Event"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "EventAttendee_userId_eventId_key" ON "EventAttendee"("userId", "eventId"); + +-- CreateIndex +CREATE UNIQUE INDEX "teams_slug_key" ON "teams"("slug"); + +-- CreateIndex +CREATE INDEX "teams_slug_idx" ON "teams"("slug"); + +-- CreateIndex +CREATE INDEX "team_members_userId_idx" ON "team_members"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "team_members_userId_teamId_key" ON "team_members"("userId", "teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_phone_number_key" ON "users"("phone_number"); + +-- AddForeignKey +ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Event" ADD CONSTRAINT "Event_organizerId_fkey" FOREIGN KEY ("organizerId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventAttendee" ADD CONSTRAINT "EventAttendee_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventAttendee" ADD CONSTRAINT "EventAttendee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "teams" ADD CONSTRAINT "teams_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; From 0c1700067cc0a71252f470f1c9c173e106d0aae7 Mon Sep 17 00:00:00 2001 From: udaycodespace Date: Wed, 17 Jun 2026 13:59:39 +0530 Subject: [PATCH 4/4] fix: resolve import ordering lint issue --- apps/backend/src/app.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 2b15545d..c7d4ad26 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -12,7 +12,6 @@ import Fastify, {type FastifyInstance, type FastifyReply, type FastifyRequest} f 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';