From 8d25a2ad1619800261f3e89f85ebec2f3984c53e Mon Sep 17 00:00:00 2001 From: Owoh Chidubem Alexander Date: Sun, 31 May 2026 04:43:26 +0100 Subject: [PATCH] Implement nonce cleanup functionality with tests --- backend/package.json | 4 - backend/src/index.ts | 3 + backend/src/routes/auth.ts | 197 ++++++++++------------------ backend/src/utils/nonce-cleanup.ts | 129 ++++++++++++++++++ backend/tests/nonce-cleanup.test.ts | 46 +++++++ 5 files changed, 249 insertions(+), 130 deletions(-) create mode 100644 backend/src/utils/nonce-cleanup.ts create mode 100644 backend/tests/nonce-cleanup.test.ts diff --git a/backend/package.json b/backend/package.json index 9ef65588..08c88d45 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,11 +7,7 @@ "dev": "nodemon src/index.ts", "build": "tsc", "start": "node dist/index.js", -{ - "scripts": { "test": "node --require ts-node/register --test tests/**/*.test.ts" - } -} }, "keywords": [], "author": "", diff --git a/backend/src/index.ts b/backend/src/index.ts index d4a6bbe3..52852b3f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -22,6 +22,7 @@ import poolRoutes from "./routes/pool"; import stateRoutes from "./routes/state"; import { pool } from "./config/db"; import { startStorageCleanup, stopStorageCleanup } from "./utils/storage-cleanup"; +import { startNonceCleanup, stopNonceCleanup } from "./utils/nonce-cleanup"; dotenv.config(); @@ -160,6 +161,7 @@ app.get("/health", async (req: Request, res: Response) => { process.on("SIGTERM", async () => { logger.info("SIGTERM received, shutting down gracefully"); stopStorageCleanup(); + stopNonceCleanup(); try { await prisma.$disconnect(); logger.info("Database connection closed"); @@ -181,6 +183,7 @@ async function bootstrap(): Promise { await connectWithRetry(); startPoolHealthCheck(); startStorageCleanup(); + startNonceCleanup(); app.listen(port, () => { console.log(`⚡️[server]: Server is running at http://localhost:${port}`); // Update pool metrics periodically so the Prometheus scrape has fresh data diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 27abcd89..32d799e7 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -9,7 +9,17 @@ import { z } from "zod"; import { Keypair, StrKey } from "@stellar/stellar-sdk"; import Redis from "ioredis"; -import { prisma } from "../config/db"; +import { prisma as prismaGlobal } from "../config/db"; + +// --------------------------------------------------------------------------- +// Mutable db reference — swapped by createAuthRouter() for testing. +// Route handlers reference `db` (the module-level binding), so reassigning +// it transparently redirects all database operations to the injected client. +// --------------------------------------------------------------------------- + +let db = prismaGlobal; + +let injectedRedisClient: Redis | null | undefined = undefined; const router = Router(); @@ -50,6 +60,9 @@ const COOKIE_BASE_OPTIONS = { let redisClient: Redis | null | undefined; function getRedisClient(): Redis | null { + if (injectedRedisClient !== undefined) { + return injectedRedisClient; + } if (redisClient !== undefined) { return redisClient; } @@ -130,11 +143,28 @@ async function isSessionBlacklisted(token: string): Promise { } async function cleanupExpiredSessions(now: Date): Promise { - await prisma.sessions.deleteMany({ + await db.sessions.deleteMany({ where: { expires_at: { lte: now } }, }); } +export async function isSessionRevoked( + redis: Redis, + token: string +): Promise { + try { + const result = await Promise.race([ + redis.get(blacklistKeyForToken(token)), + new Promise((resolve) => + setTimeout(() => resolve(null), BLACKLIST_TIMEOUT_MS) + ), + ]); + return result !== null; + } catch { + return false; + } +} + export function sanitizeStellarAddress( rawAddress: unknown ): string | null { @@ -208,31 +238,6 @@ function extractSignatureString( } return null; -/** - * Safely decodes a signature from either hex or base64 format. - * Enforces strict bounds checking: ed25519 signatures are exactly 64 bytes. - * Rejects any signature that decodes to a length other than 64 bytes. - */ -function decodeSignature(raw: string): Buffer { - const trimmed = raw.trim(); - if (trimmed.length === 0) { - throw new Error("Signature cannot be empty"); - } - - let buf: Buffer; - if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length % 2 === 0) { - buf = Buffer.from(trimmed, "hex"); - } else { - buf = Buffer.from(trimmed, "base64"); - } - - // ed25519 signatures are exactly 64 bytes — reject any other size. - if (buf.length !== 64) { - throw new Error( - `Invalid signature length: expected 64 bytes, got ${buf.length}` - ); - } - return buf; } export function decodeSignature( @@ -384,7 +389,7 @@ async function issueRefreshToken( previousTokenId?: number ): Promise<{ rawToken: string; hashedToken: string }> { if (previousTokenId !== undefined) { - await prisma.refresh_tokens.update({ + await db.refresh_tokens.update({ where: { id: previousTokenId }, data: { revoked: true }, }); @@ -403,7 +408,7 @@ async function issueRefreshToken( Date.now() + REFRESH_TOKEN_TTL_SEC * 1000 ); - await prisma.refresh_tokens.create({ + await db.refresh_tokens.create({ data: { token_hash: hashedToken, address, @@ -496,7 +501,7 @@ router.post( Date.now() + CHALLENGE_TTL_MS ); - await prisma.$transaction(async (tx) => { + await db.$transaction(async (tx) => { await tx.auth_challenges.deleteMany({ where: { expires_at: { lte: new Date() }, @@ -572,7 +577,7 @@ router.post( } const challengeRecord = - await prisma.auth_challenges.findUnique({ + await db.auth_challenges.findUnique({ where: { address }, }); @@ -583,7 +588,7 @@ router.post( } if (!isChallengeFresh(challengeRecord)) { - await prisma.auth_challenges + await db.auth_challenges .deleteMany({ where: { address, @@ -622,7 +627,7 @@ router.post( } const deleted = - await prisma.auth_challenges.deleteMany({ + await db.auth_challenges.deleteMany({ where: { address, challenge: challengeRecord.challenge, @@ -652,7 +657,7 @@ router.post( Date.now() + SESSION_TTL_MS ); - await prisma.sessions.create({ + await db.sessions.create({ data: { token: sessionToken, address, @@ -690,95 +695,6 @@ router.post( }); } } - "/verify", - async (req: Request<{}, {}, VerifyBody>, res: Response) => { - try { - const parsed = VerifyRequestSchema.safeParse(req.body); - - if (!parsed.success) { - return res.status(400).json({ error: "Invalid request body" }); - } - - const address = sanitizeStellarAddress(parsed.data.address); - - if (!address) { - return res.status(400).json({ error: "Invalid Stellar address" }); - } - - let signature = parsed.data.signature; - - if (typeof signature === "object" && "signature" in signature) { - signature = signature.signature; - } - - const challengeRecord = await prisma.auth_challenges.findUnique({ - where: { address }, - }); - - // Return 401 (not 404) to avoid leaking whether an address has a pending challenge. - if (!challengeRecord) { - return res.status(401).json({ error: "Invalid credentials" }); - } - - if (!isChallengeFresh(challengeRecord)) { - return res.status(401).json({ error: "Challenge expired" }); - } - - const isValid = verifyStellarSignature( - address, - challengeRecord.challenge, - signature - ); - - if (!isValid) { - return res.status(401).json({ error: "Invalid signature" }); - } - - // Atomically consume the challenge. count === 0 means another concurrent - // request already used it (TOCTOU guard). - const deleted = await prisma.auth_challenges.deleteMany({ - where: { - address, - challenge: challengeRecord.challenge, - expires_at: { gt: new Date() }, - }, - }); - - if (deleted.count === 0) { - return res.status(401).json({ error: "Challenge already consumed" }); - } - - const accessJti = crypto.randomUUID(); - const accessToken = issueAccessToken(address, accessJti); - - const sessionToken = crypto.randomBytes(48).toString("base64url"); - const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_SEC * 1000); - - await prisma.sessions.create({ - data: { token: sessionToken, address, expires_at: expiresAt }, - }); - - res.cookie(ACCESS_TOKEN_COOKIE, accessToken, { - ...COOKIE_BASE_OPTIONS, - maxAge: ACCESS_TOKEN_TTL_SEC * 1000, - }); - - res.cookie(REFRESH_TOKEN_COOKIE, sessionToken, { - ...COOKIE_BASE_OPTIONS, - maxAge: REFRESH_TOKEN_TTL_SEC * 1000, - }); - - return res.status(200).json({ - access_token: accessToken, - refresh_token: sessionToken, - token_type: "Bearer", - expires_in: ACCESS_TOKEN_TTL_SEC, - }); - } catch (error) { - console.error("[auth/verify]", error); - return res.status(500).json({ error: "Internal server error" }); - } - } ); interface RefreshBody { @@ -824,7 +740,7 @@ router.post( .digest("hex"); const record = - await prisma.refresh_tokens.findUnique({ + await db.refresh_tokens.findUnique({ where: { token_hash: incomingHash, }, @@ -979,7 +895,7 @@ router.post( .update(refreshToken) .digest("hex"); - await prisma.refresh_tokens + await db.refresh_tokens .updateMany({ where: { token_hash: hash, @@ -1069,7 +985,7 @@ router.get( const now = new Date(); const session = - await prisma.sessions.findUnique({ + await db.sessions.findUnique({ where: { token }, }); @@ -1104,4 +1020,33 @@ router.get( } ); +/** + * createAuthRouter — returns the shared router with database and Redis + * dependencies overridden for testing. + * + * When `prismaClient` is provided, all route handlers use it instead of the + * global Prisma client. When `redisClient` is explicitly provided (including + * `null`), the Redis-backed blacklist helpers use it instead. + * + * @example + * ```ts + * const router = createAuthRouter({ + * prismaClient: mockPrisma, + * redisClient: null, + * }); + * ``` + */ +export function createAuthRouter(options: { + prismaClient?: typeof db; + redisClient?: Redis | null; +}): Router { + if (options.prismaClient) { + db = options.prismaClient; + } + if (options.redisClient !== undefined) { + injectedRedisClient = options.redisClient; + } + return router; +} + export default router; \ No newline at end of file diff --git a/backend/src/utils/nonce-cleanup.ts b/backend/src/utils/nonce-cleanup.ts new file mode 100644 index 00000000..a684e67b --- /dev/null +++ b/backend/src/utils/nonce-cleanup.ts @@ -0,0 +1,129 @@ +import { prisma } from "../config/db"; +import { trace } from "../config/tracing"; + +const logger = trace.getLogger("nonce-cleanup"); + +const CLEANUP_INTERVAL_MS = parseInt( + process.env.NONCE_CLEANUP_INTERVAL_MS || (5 * 60 * 1000).toString(), + 10 +); + +export interface NonceCleanupStats { + lastRunAt: string | null; + lastRunOk: boolean; + lastError: string | null; + challengesCleaned: number; + sessionsCleaned: number; + refreshTokensCleaned: number; + intervalMs: number; +} + +let lastRunAt: Date | null = null; +let lastRunOk = true; +let lastError: string | null = null; +let challengesCleaned = 0; +let sessionsCleaned = 0; +let refreshTokensCleaned = 0; + +export function getNonceCleanupStats(): NonceCleanupStats { + return { + lastRunAt: lastRunAt ? lastRunAt.toISOString() : null, + lastRunOk, + lastError, + challengesCleaned, + sessionsCleaned, + refreshTokensCleaned, + intervalMs: CLEANUP_INTERVAL_MS, + }; +} + +async function cleanExpiredChallenges(): Promise { + const now = new Date(); + const result = await prisma.auth_challenges.deleteMany({ + where: { expires_at: { lte: now } }, + }); + if (result.count > 0) { + logger.info("Cleaned expired auth challenges", { count: result.count }); + } + return result.count; +} + +async function cleanExpiredSessions(): Promise { + const now = new Date(); + const result = await prisma.sessions.deleteMany({ + where: { expires_at: { lte: now } }, + }); + if (result.count > 0) { + logger.info("Cleaned expired sessions", { count: result.count }); + } + return result.count; +} + +async function cleanExpiredRefreshTokens(): Promise { + const now = new Date(); + const result = await prisma.refresh_tokens.deleteMany({ + where: { + OR: [ + { expires_at: { lte: now } }, + { revoked: true }, + ], + }, + }); + if (result.count > 0) { + logger.info("Cleaned expired/revoked refresh tokens", { count: result.count }); + } + return result.count; +} + +async function runCleanupCycle(): Promise { + try { + const [challenges, sessions, tokens] = await Promise.all([ + cleanExpiredChallenges(), + cleanExpiredSessions(), + cleanExpiredRefreshTokens(), + ]); + + challengesCleaned += challenges; + sessionsCleaned += sessions; + refreshTokensCleaned += tokens; + + if (challenges > 0 || sessions > 0 || tokens > 0) { + logger.info("Nonce cleanup cycle completed", { + challengesCleaned: challenges, + sessionsCleaned: sessions, + refreshTokensCleaned: tokens, + }); + } + + lastRunOk = true; + lastError = null; + } catch (err: any) { + lastRunOk = false; + lastError = err.message; + logger.error("Nonce cleanup cycle failed", { error: err.message }); + } finally { + lastRunAt = new Date(); + } +} + +let cleanupTimer: ReturnType | null = null; + +export function startNonceCleanup(): void { + if (cleanupTimer) return; + + runCleanupCycle(); + cleanupTimer = setInterval(runCleanupCycle, CLEANUP_INTERVAL_MS); + if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) { + cleanupTimer.unref(); + } + + logger.info(`Nonce cleanup job started (interval: ${CLEANUP_INTERVAL_MS}ms)`); +} + +export function stopNonceCleanup(): void { + if (cleanupTimer) { + clearInterval(cleanupTimer); + cleanupTimer = null; + logger.info("Nonce cleanup job stopped"); + } +} diff --git a/backend/tests/nonce-cleanup.test.ts b/backend/tests/nonce-cleanup.test.ts new file mode 100644 index 00000000..014e178c --- /dev/null +++ b/backend/tests/nonce-cleanup.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import Module from "node:module"; + +const originalLoad = (Module as any)._load; +(Module as any)._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { + if (request === "../config/db") { + return { prisma: {} }; + } + return originalLoad.apply(this, [request, parent, isMain]); +}; + +const { startNonceCleanup, stopNonceCleanup, getNonceCleanupStats } = require("../src/utils/nonce-cleanup") as typeof import("../src/utils/nonce-cleanup"); + +test("nonce cleanup starts and stops without error", () => { + startNonceCleanup(); + const stats = getNonceCleanupStats(); + assert.equal(typeof stats.intervalMs, "number"); + assert.equal(stats.intervalMs > 0, true); + stopNonceCleanup(); +}); + +test("nonce cleanup stats have expected shape", () => { + stopNonceCleanup(); + const stats = getNonceCleanupStats(); + assert.ok("lastRunAt" in stats); + assert.ok("lastRunOk" in stats); + assert.ok("lastError" in stats); + assert.ok("challengesCleaned" in stats); + assert.ok("sessionsCleaned" in stats); + assert.ok("refreshTokensCleaned" in stats); + assert.ok("intervalMs" in stats); + assert.equal(typeof stats.challengesCleaned, "number"); + assert.equal(typeof stats.sessionsCleaned, "number"); + assert.equal(typeof stats.refreshTokensCleaned, "number"); +}); + +test("nonce cleanup idempotent start does not double-initialize", () => { + stopNonceCleanup(); + startNonceCleanup(); + startNonceCleanup(); + startNonceCleanup(); + const stats = getNonceCleanupStats(); + assert.equal(typeof stats.intervalMs, "number"); + stopNonceCleanup(); +});