diff --git a/backend/src/index.ts b/backend/src/index.ts index 75d20f4e..92f486fd 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(); @@ -151,6 +152,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"); @@ -172,6 +174,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/middleware/authGuard.ts b/backend/src/middleware/authGuard.ts index caae7093..cf158912 100644 --- a/backend/src/middleware/authGuard.ts +++ b/backend/src/middleware/authGuard.ts @@ -39,8 +39,9 @@ export async function authGuard( res: Response, next: NextFunction ): Promise { - // Try to get token from cookie first, then from Authorization header - let token = req.cookies[ACCESS_TOKEN_COOKIE]; + // Try to get token from cookie first (cookie-parser adds req.cookies; + // when unavailable, skip gracefully), then from Authorization header. + let token = req.cookies?.[ACCESS_TOKEN_COOKIE]; const header = req.headers.authorization; if (!token && header?.startsWith("Bearer ")) { token = header.slice(7); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index bc4dbc48..ff661918 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,5 +1,8 @@ /** * auth.ts — Secure JWT Session + Refresh Token Flow + * Includes: SEP-53 Stellar signature verification, JWT issuance, + * refresh-token rotation, Redis-backed blacklisting, and admin + * dispute-override endpoints. */ import { Router, Request, Response } from "express"; @@ -9,7 +12,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(); @@ -66,7 +79,9 @@ function extractClientIp(req: Request): string { return req.ip || req.socket.remoteAddress || "127.0.0.1"; } -function takeChallengeToken(ip: string): { ok: true } | { ok: false; retryAfter: number } { +function takeChallengeToken( + ip: string, +): { ok: true } | { ok: false; retryAfter: number } { const cap = CHALLENGE_RATE_LIMIT_RPM + CHALLENGE_RATE_LIMIT_BURST; const refillPerMs = CHALLENGE_RATE_LIMIT_RPM / 60_000; const now = Date.now(); @@ -82,7 +97,10 @@ function takeChallengeToken(ip: string): { ok: true } | { ok: false; retryAfter: bucket.lastMs = now; if (bucket.tokens < 1) { - const retryAfter = Math.max(1, Math.ceil((1 - bucket.tokens) / refillPerMs / 1000)); + const retryAfter = Math.max( + 1, + Math.ceil((1 - bucket.tokens) / refillPerMs / 1000), + ); return { ok: false, retryAfter }; } @@ -108,6 +126,9 @@ if (challengePruneTimer && "unref" in challengePruneTimer) { let redisClient: Redis | null | undefined; function getRedisClient(): Redis | null { + if (injectedRedisClient !== undefined) { + return injectedRedisClient; + } if (redisClient !== undefined) { return redisClient; } @@ -177,7 +198,7 @@ async function isSessionBlacklisted(token: string): Promise { const result = await Promise.race([ client.get(blacklistKeyForToken(token)), new Promise((resolve) => - setTimeout(() => resolve(null), BLACKLIST_TIMEOUT_MS) + setTimeout(() => resolve(null), BLACKLIST_TIMEOUT_MS), ), ]); @@ -187,16 +208,37 @@ async function isSessionBlacklisted(token: string): Promise { } } +// export async function isSessionRevoked( +// redisClient: Redis | null, +// sessionToken: string, +// ): Promise { +// if (!redisClient) return false; +// try { +// const result = await Promise.race([ +// redisClient.get(blacklistKeyForToken(sessionToken)), +// new Promise((resolve) => setTimeout(() => resolve(null), 1)), +// ]); +// return result !== null; +// } catch { +// return false; +// } +// } + +async function cleanupExpiredSessions(now: Date): Promise { + await db.sessions.deleteMany({ + where: { expires_at: { lte: now } }, + }); +} + export async function isSessionRevoked( - redisClient: Redis | null, - sessionToken: string + redis: Redis, + token: string, ): Promise { - if (!redisClient) return false; try { const result = await Promise.race([ - redisClient.get(blacklistKeyForToken(sessionToken)), + redis.get(blacklistKeyForToken(token)), new Promise((resolve) => - setTimeout(() => resolve(null), 1) + setTimeout(() => resolve(null), BLACKLIST_TIMEOUT_MS), ), ]); return result !== null; @@ -205,15 +247,7 @@ export async function isSessionRevoked( } } -async function cleanupExpiredSessions(now: Date): Promise { - await prisma.sessions.deleteMany({ - where: { expires_at: { lte: now } }, - }); -} - -export function sanitizeStellarAddress( - rawAddress: unknown -): string | null { +export function sanitizeStellarAddress(rawAddress: unknown): string | null { if (typeof rawAddress !== "string" || rawAddress.length === 0) { return null; } @@ -225,10 +259,7 @@ export function sanitizeStellarAddress( try { const decoded = StrKey.decodeEd25519PublicKey(rawAddress); - if ( - decoded.length !== 32 || - !StrKey.isValidEd25519PublicKey(rawAddress) - ) { + if (decoded.length !== 32 || !StrKey.isValidEd25519PublicKey(rawAddress)) { return null; } @@ -243,7 +274,7 @@ export function sanitizeStellarAddress( } export function validateStellarAddress( - rawAddress: unknown + rawAddress: unknown, ): { valid: true; address: string } | { valid: false; error: string } { if (typeof rawAddress !== "string") { return { valid: false, error: "Invalid Stellar address format" }; @@ -258,10 +289,7 @@ export function validateStellarAddress( try { const decoded = StrKey.decodeEd25519PublicKey(normalized); - if ( - decoded.length !== 32 || - !StrKey.isValidEd25519PublicKey(normalized) - ) { + if (decoded.length !== 32 || !StrKey.isValidEd25519PublicKey(normalized)) { return { valid: false, error: "Invalid Stellar address checksum" }; } @@ -277,10 +305,7 @@ export function validateStellarAddress( } } -export function buildChallenge( - address: string, - nonce: string -): string { +export function buildChallenge(address: string, nonce: string): string { const issuedAt = new Date().toISOString(); return ( @@ -290,17 +315,12 @@ export function buildChallenge( } function buildMessageHash(challenge: string): Buffer { - const payload = Buffer.from( - STELLAR_SIGN_PREFIX + challenge, - "utf8" - ); + const payload = Buffer.from(STELLAR_SIGN_PREFIX + challenge, "utf8"); return crypto.createHash("sha256").update(payload).digest(); } -function extractSignatureString( - signature: unknown -): string | null { +function extractSignatureString(signature: unknown): string | null { if (typeof signature === "string") { return signature.trim(); } @@ -308,8 +328,7 @@ function extractSignatureString( if (signature && typeof signature === "object") { const wrapped = signature as Record; - const candidate = - wrapped.signature ?? wrapped.signedMessage; + const candidate = wrapped.signature ?? wrapped.signedMessage; if (typeof candidate === "string") { return candidate.trim(); @@ -319,9 +338,7 @@ function extractSignatureString( return null; } -export function decodeSignature( - signature: unknown -): Buffer | null { +export function decodeSignature(signature: unknown): Buffer | null { const sigString = extractSignatureString(signature); if (!sigString) { @@ -330,10 +347,7 @@ export function decodeSignature( const candidates: Buffer[] = []; - if ( - /^[0-9a-fA-F]+$/.test(sigString) && - sigString.length % 2 === 0 - ) { + if (/^[0-9a-fA-F]+$/.test(sigString) && sigString.length % 2 === 0) { candidates.push(Buffer.from(sigString, "hex")); } @@ -343,20 +357,14 @@ export function decodeSignature( if (/^[A-Za-z0-9_-]+={0,2}$/.test(sigString)) { candidates.push( - Buffer.from( - sigString.replace(/-/g, "+").replace(/_/g, "/"), - "base64" - ) + Buffer.from(sigString.replace(/-/g, "+").replace(/_/g, "/"), "base64"), ); } return candidates.find((candidate) => candidate.length === 64) ?? null; } -function timingSafeEqualStrings( - a: string, - b: string -): boolean { +function timingSafeEqualStrings(a: string, b: string): boolean { const aBuf = Buffer.from(a); const bBuf = Buffer.from(b); @@ -370,11 +378,10 @@ function timingSafeEqualStrings( export function verifyStellarSignature( address: string, challenge: string, - signature: unknown + signature: unknown, ): boolean { try { - const normalizedAddress = - sanitizeStellarAddress(address); + const normalizedAddress = sanitizeStellarAddress(address); const signatureBuffer = decodeSignature(signature); @@ -382,13 +389,9 @@ export function verifyStellarSignature( return false; } - const keypair = - Keypair.fromPublicKey(normalizedAddress); + const keypair = Keypair.fromPublicKey(normalizedAddress); - return keypair.verify( - buildMessageHash(challenge), - signatureBuffer - ); + return keypair.verify(buildMessageHash(challenge), signatureBuffer); } catch { return false; } @@ -396,7 +399,7 @@ export function verifyStellarSignature( export function isChallengeFresh( record: { expires_at: Date }, - now: Date = new Date() + now: Date = new Date(), ): boolean { return record.expires_at.getTime() > now.getTime(); } @@ -405,9 +408,7 @@ function extractBearerToken(req: Request): string | null { const authorization = req.header("authorization"); if (authorization?.startsWith("Bearer ")) { - return authorization - .slice("Bearer ".length) - .trim(); + return authorization.slice("Bearer ".length).trim(); } const cookieHeader = req.header("cookie"); @@ -416,18 +417,14 @@ function extractBearerToken(req: Request): string | null { return null; } - const cookies = cookieHeader - .split(";") - .map((cookie) => cookie.trim()); + const cookies = cookieHeader.split(";").map((cookie) => cookie.trim()); const sessionCookie = cookies.find((cookie) => - cookie.startsWith(`${SESSION_COOKIE_NAME}=`) + cookie.startsWith(`${SESSION_COOKIE_NAME}=`), ); return sessionCookie - ? decodeURIComponent( - sessionCookie.split("=").slice(1).join("=") - ) + ? decodeURIComponent(sessionCookie.split("=").slice(1).join("=")) : null; } @@ -435,17 +432,11 @@ function extractBearerToken(req: Request): string | null { // JWT Helpers // --------------------------------------------------------------------------- -function issueAccessToken( - address: string, - jti: string, - role?: string -): string { +function issueAccessToken(address: string, jti: string, role?: string): string { const secret = process.env.JWT_SECRET; if (!secret) { - throw new Error( - "JWT_SECRET environment variable is not set" - ); + throw new Error("JWT_SECRET environment variable is not set"); } const options: SignOptions = { @@ -456,27 +447,23 @@ function issueAccessToken( audience: "lance-frontend", }; - return jwt.sign( - { address, ...(role ? { role } : {}) }, - secret, - options - ); + return jwt.sign({ address, ...(role ? { role } : {}) }, secret, options); } async function issueRefreshToken( address: string, - previousTokenId?: number + previousTokenId?: number, ): Promise<{ rawToken: string; hashedToken: string }> { + const client = db ?? prisma; + if (previousTokenId !== undefined) { - await prisma.refresh_tokens.update({ + await db.refresh_tokens.update({ where: { id: previousTokenId }, data: { revoked: true, revoked_at: new Date() }, }); } - const rawToken = crypto - .randomBytes(48) - .toString("base64url"); + const rawToken = crypto.randomBytes(48).toString("base64url"); const jti = crypto.randomUUID(); const hashedToken = crypto @@ -484,11 +471,9 @@ async function issueRefreshToken( .update(rawToken) .digest("hex"); - const expiresAt = new Date( - Date.now() + REFRESH_TOKEN_TTL_SEC * 1000 - ); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_SEC * 1000); - await prisma.refresh_tokens.create({ + await db.refresh_tokens.create({ data: { token_hash: hashedToken, address, @@ -503,7 +488,7 @@ async function issueRefreshToken( export async function blacklistToken( jti: string, - expiresAt: number + expiresAt: number, ): Promise { const client = getRedisClient(); @@ -511,32 +496,19 @@ export async function blacklistToken( return; } - const ttlSeconds = Math.max( - 1, - expiresAt - Math.floor(Date.now() / 1000) - ); + const ttlSeconds = Math.max(1, expiresAt - Math.floor(Date.now() / 1000)); - await client.set( - `${BLACKLIST_NS}${jti}`, - "1", - "EX", - ttlSeconds, - "NX" - ); + await client.set(`${BLACKLIST_NS}${jti}`, "1", "EX", ttlSeconds, "NX"); } -export async function isTokenBlacklisted( - jti: string -): Promise { +export async function isTokenBlacklisted(jti: string): Promise { const client = getRedisClient(); if (!client) { return false; } - const result = await client.get( - `${BLACKLIST_NS}${jti}` - ); + const result = await client.get(`${BLACKLIST_NS}${jti}`); return result !== null; } @@ -551,10 +523,7 @@ interface ChallengeBody { router.post( "/challenge", - async ( - req: Request<{}, {}, ChallengeBody>, - res: Response - ) => { + async (req: Request<{}, {}, ChallengeBody>, res: Response) => { try { const ip = extractClientIp(req); const rateResult = takeChallengeToken(ip); @@ -567,8 +536,7 @@ router.post( }); } - const parsed = - ChallengeRequestSchema.safeParse(req.body); + const parsed = ChallengeRequestSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ @@ -576,9 +544,7 @@ router.post( }); } - const validation = validateStellarAddress( - parsed.data.address - ); + const validation = validateStellarAddress(parsed.data.address); if (!validation.valid) { return res.status(400).json({ @@ -591,11 +557,9 @@ router.post( const nonce = crypto.randomUUID(); const challenge = buildChallenge(address, nonce); - const expiresAt = new Date( - Date.now() + CHALLENGE_TTL_MS - ); + const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS); - await prisma.$transaction(async (tx: any) => { + await db.$transaction(async (tx: any) => { await tx.auth_challenges.deleteMany({ where: { expires_at: { lte: new Date() }, @@ -627,7 +591,7 @@ router.post( error: "Internal server error", }); } - } + }, ); interface VerifyBody { @@ -637,13 +601,9 @@ interface VerifyBody { router.post( "/verify", - async ( - req: Request<{}, {}, VerifyBody>, - res: Response - ) => { + async (req: Request<{}, {}, VerifyBody>, res: Response) => { try { - const parsed = - VerifyRequestSchema.safeParse(req.body); + const parsed = VerifyRequestSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ @@ -651,9 +611,7 @@ router.post( }); } - const address = sanitizeStellarAddress( - parsed.data.address - ); + const address = sanitizeStellarAddress(parsed.data.address); if (!address) { return res.status(400).json({ @@ -663,17 +621,13 @@ router.post( let signature = parsed.data.signature; - if ( - typeof signature === "object" && - "signature" in signature - ) { + if (typeof signature === "object" && "signature" in signature) { signature = signature.signature; } - const challengeRecord = - await prisma.auth_challenges.findUnique({ - where: { address }, - }); + const challengeRecord = await db.auth_challenges.findUnique({ + where: { address }, + }); if (!challengeRecord) { return res.status(401).json({ @@ -682,7 +636,7 @@ router.post( } if (!isChallengeFresh(challengeRecord)) { - await prisma.auth_challenges + await db.auth_challenges .deleteMany({ where: { address, @@ -699,16 +653,13 @@ router.post( let isValid = verifyStellarSignature( address, challengeRecord.challenge, - signature + signature, ); if (!isValid && process.env.NODE_ENV !== "production") { if ( signature === "mock-signature" || - timingSafeEqualStrings( - signature, - challengeRecord.challenge - ) + timingSafeEqualStrings(signature, challengeRecord.challenge) ) { isValid = true; } @@ -720,14 +671,13 @@ router.post( }); } - const deleted = - await prisma.auth_challenges.deleteMany({ - where: { - address, - challenge: challengeRecord.challenge, - expires_at: { gt: new Date() }, - }, - }); + const deleted = await db.auth_challenges.deleteMany({ + where: { + address, + challenge: challengeRecord.challenge, + expires_at: { gt: new Date() }, + }, + }); if (deleted.count === 0) { return res.status(401).json({ @@ -737,21 +687,15 @@ router.post( const accessJti = crypto.randomUUID(); - const accessToken = issueAccessToken( - address, - accessJti - ); + const accessToken = issueAccessToken(address, accessJti); - const { rawToken: refreshToken } = - await issueRefreshToken(address); + const { rawToken: refreshToken } = await issueRefreshToken(address); const sessionToken = crypto.randomUUID(); - const sessionExpiresAt = new Date( - Date.now() + SESSION_TTL_MS - ); + const sessionExpiresAt = new Date(Date.now() + SESSION_TTL_MS); - await prisma.sessions.create({ + await db.sessions.create({ data: { token: sessionToken, address, @@ -788,22 +732,47 @@ router.post( error: "Internal server error", }); } - } + }, ); +// --------------------------------------------------------------------------- +// Additional Exports for Testing / Admin Overrides +// --------------------------------------------------------------------------- + +export function normalizeStellarAddress(rawAddress: unknown): string | null { + return sanitizeStellarAddress(rawAddress); +} + +export function isChallengeExpired(expiresAt: Date): boolean { + return expiresAt.getTime() <= Date.now(); +} + +export async function isSessionRevoked( + client: { get(key: string): Promise } | Redis, + token: string, +): Promise { + try { + const result = await Promise.race([ + client.get(`${SESSION_BLACKLIST_NS}${sha256Hex(token)}`), + new Promise((resolve) => + setTimeout(() => resolve(null), BLACKLIST_TIMEOUT_MS), + ), + ]); + return result !== null; + } catch { + return false; + } +} + interface RefreshBody { refresh_token?: string; } router.post( "/refresh", - async ( - req: Request<{}, {}, RefreshBody>, - res: Response - ) => { + async (req: Request<{}, {}, RefreshBody>, res: Response) => { try { - const parsed = - RefreshRequestSchema.safeParse(req.body); + const parsed = RefreshRequestSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ @@ -811,18 +780,13 @@ router.post( }); } - let refreshToken = - parsed.data.refresh_token; + let refreshToken = parsed.data.refresh_token; if (!refreshToken) { - refreshToken = - req.cookies?.[REFRESH_TOKEN_COOKIE]; + refreshToken = req.cookies?.[REFRESH_TOKEN_COOKIE]; } - if ( - !refreshToken || - typeof refreshToken !== "string" - ) { + if (!refreshToken || typeof refreshToken !== "string") { return res.status(400).json({ error: "refresh_token is required", }); @@ -833,12 +797,11 @@ router.post( .update(refreshToken) .digest("hex"); - const record = - await prisma.refresh_tokens.findUnique({ - where: { - token_hash: incomingHash, - }, - }); + const record = await db.refresh_tokens.findUnique({ + where: { + token_hash: incomingHash, + }, + }); if (!record) { return res.status(401).json({ @@ -848,55 +811,34 @@ router.post( if (record.revoked) { return res.status(401).json({ - error: - "Refresh token has been revoked", + error: "Refresh token has been revoked", }); } - if ( - record.expires_at.getTime() <= - Date.now() - ) { + if (record.expires_at.getTime() <= Date.now()) { return res.status(401).json({ error: "Refresh token expired", }); } - const newAccessJti = - crypto.randomUUID(); + const newAccessJti = crypto.randomUUID(); - const newAccessToken = - issueAccessToken( - record.address, - newAccessJti - ); + const newAccessToken = issueAccessToken(record.address, newAccessJti); - const { - rawToken: newRefreshToken, - } = await issueRefreshToken( + const { rawToken: newRefreshToken } = await issueRefreshToken( record.address, - record.id + record.id, ); - res.cookie( - ACCESS_TOKEN_COOKIE, - newAccessToken, - { - ...COOKIE_BASE_OPTIONS, - maxAge: - ACCESS_TOKEN_TTL_SEC * 1000, - } - ); + res.cookie(ACCESS_TOKEN_COOKIE, newAccessToken, { + ...COOKIE_BASE_OPTIONS, + maxAge: ACCESS_TOKEN_TTL_SEC * 1000, + }); - res.cookie( - REFRESH_TOKEN_COOKIE, - newRefreshToken, - { - ...COOKIE_BASE_OPTIONS, - maxAge: - REFRESH_TOKEN_TTL_SEC * 1000, - } - ); + res.cookie(REFRESH_TOKEN_COOKIE, newRefreshToken, { + ...COOKIE_BASE_OPTIONS, + maxAge: REFRESH_TOKEN_TTL_SEC * 1000, + }); return res.status(200).json({ access_token: newAccessToken, @@ -911,266 +853,221 @@ router.post( error: "Internal server error", }); } - } + }, ); -router.post( - "/logout", - async (req: Request, res: Response) => { - try { - let rawAccessToken = - req.cookies?.[ACCESS_TOKEN_COOKIE]; - - const authHeader = - req.headers.authorization; - - if ( - !rawAccessToken && - authHeader?.startsWith("Bearer ") - ) { - rawAccessToken = - authHeader.slice(7); - } +router.post("/logout", async (req: Request, res: Response) => { + try { + let rawAccessToken = req.cookies?.[ACCESS_TOKEN_COOKIE]; - let refreshToken = - req.cookies?.[ - REFRESH_TOKEN_COOKIE - ]; + const authHeader = req.headers.authorization; - const body = - req.body as RefreshBody; + if (!rawAccessToken && authHeader?.startsWith("Bearer ")) { + rawAccessToken = authHeader.slice(7); + } - if ( - !refreshToken && - body.refresh_token - ) { - refreshToken = - body.refresh_token; - } + let refreshToken = req.cookies?.[REFRESH_TOKEN_COOKIE]; - if (rawAccessToken) { - const secret = - process.env.JWT_SECRET; + const body = req.body as RefreshBody; - if (secret) { - try { - const decoded = jwt.verify( - rawAccessToken, - secret, - { - issuer: - "lance-marketplace", - audience: - "lance-frontend", - } - ) as JwtPayload; - - if ( - decoded.jti && - decoded.exp - ) { - await blacklistToken( - decoded.jti, - decoded.exp - ); - } - } catch { - // Ignore invalid/expired token - } - } - } + if (!refreshToken && body.refresh_token) { + refreshToken = body.refresh_token; + } - if ( - refreshToken && - typeof refreshToken === "string" - ) { - const hash = crypto - .createHash("sha256") - .update(refreshToken) - .digest("hex"); + if (rawAccessToken) { + const secret = process.env.JWT_SECRET; - await prisma.refresh_tokens - .updateMany({ - where: { - token_hash: hash, - revoked: false, - }, - data: { - revoked: true, - }, - }) - .catch(() => {}); - } + if (secret) { + try { + const decoded = jwt.verify(rawAccessToken, secret, { + issuer: "lance-marketplace", + audience: "lance-frontend", + }) as JwtPayload; - const sessionToken = - extractBearerToken(req); - - if (sessionToken) { - const client = - getRedisClient(); - - if (client) { - await client.set( - blacklistKeyForToken( - sessionToken - ), - "1", - "EX", - REFRESH_TOKEN_TTL_SEC, - "NX" - ); + if (decoded.jti && decoded.exp) { + await blacklistToken(decoded.jti, decoded.exp); + } + } catch { + // Ignore invalid/expired token } } + } - res.clearCookie( - ACCESS_TOKEN_COOKIE, - COOKIE_BASE_OPTIONS - ); + if (refreshToken && typeof refreshToken === "string") { + const hash = crypto + .createHash("sha256") + .update(refreshToken) + .digest("hex"); - res.clearCookie( - REFRESH_TOKEN_COOKIE, - COOKIE_BASE_OPTIONS - ); + await db.refresh_tokens + .updateMany({ + where: { + token_hash: hash, + revoked: false, + }, + data: { + revoked: true, + }, + }) + .catch(() => {}); + } - res.clearCookie( - SESSION_COOKIE_NAME, - COOKIE_BASE_OPTIONS - ); + const sessionToken = extractBearerToken(req); - return res.status(200).json({ - message: - "Logged out successfully", - }); - } catch (error) { - console.error("[auth/logout]", error); + if (sessionToken) { + const client = getRedisClient(); - return res.status(500).json({ - error: "Internal server error", - }); + if (client) { + await client.set( + blacklistKeyForToken(sessionToken), + "1", + "EX", + REFRESH_TOKEN_TTL_SEC, + "NX", + ); + } } - } -); -router.get( - "/session", - async (req: Request, res: Response) => { - try { - const token = - extractBearerToken(req); + res.clearCookie(ACCESS_TOKEN_COOKIE, COOKIE_BASE_OPTIONS); - if (!token) { - return res.status(401).json({ - error: - "Session token is required", - }); - } + res.clearCookie(REFRESH_TOKEN_COOKIE, COOKIE_BASE_OPTIONS); - if ( - await isSessionBlacklisted( - token - ) - ) { - return res.status(401).json({ - error: - "Session has been revoked", - }); - } + res.clearCookie(SESSION_COOKIE_NAME, COOKIE_BASE_OPTIONS); - const now = new Date(); + return res.status(200).json({ + message: "Logged out successfully", + }); + } catch (error) { + console.error("[auth/logout]", error); - const session = - await prisma.sessions.findUnique({ - where: { token }, - }); + return res.status(500).json({ + error: "Internal server error", + }); + } +}); - if ( - !session || - session.expires_at <= now - ) { - if (session) { - await cleanupExpiredSessions( - now - ); - } +router.get("/session", async (req: Request, res: Response) => { + try { + const token = extractBearerToken(req); - return res.status(401).json({ - error: - "Session expired or not found", - }); - } + if (!token) { + return res.status(401).json({ + error: "Session token is required", + }); + } - return res.json({ - address: session.address, - expires_at: - session.expires_at.toISOString(), + if (await isSessionBlacklisted(token)) { + return res.status(401).json({ + error: "Session has been revoked", }); - } catch (error) { - console.error("[auth/session]", error); + } - return res.status(500).json({ - error: "Internal server error", + const now = new Date(); + + const session = await db.sessions.findUnique({ + where: { token }, + }); + + if (!session || session.expires_at <= now) { + if (session) { + await cleanupExpiredSessions(now); + } + + return res.status(401).json({ + error: "Session expired or not found", }); } + + return res.json({ + address: session.address, + expires_at: session.expires_at.toISOString(), + }); + } catch (error) { + console.error("[auth/session]", error); + + return res.status(500).json({ + error: "Internal server error", + }); } -); +}); + +/** + * 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, + * }); + * ``` + */ // --------------------------------------------------------------------------- // DI-enabled Router Factory (for testing with mocked dependencies) // --------------------------------------------------------------------------- export function createAuthRouter(deps: { - prismaClient: typeof prisma; + prismaClient: any; redisClient: Redis | null; }): Router { const r = Router(); const { prismaClient, redisClient: depsRedis } = deps; - r.post("/challenge", async (req: Request<{}, {}, ChallengeBody>, res: Response) => { - try { - const ip = extractClientIp(req); - const rateResult = takeChallengeToken(ip); - - if (!rateResult.ok) { - res.setHeader("Retry-After", String(rateResult.retryAfter)); - return res.status(429).json({ - error: "rate limit exceeded", - retry_after_seconds: rateResult.retryAfter, - }); - } - - const parsed = ChallengeRequestSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: "Invalid request body" }); - } + r.post( + "/challenge", + async (req: Request<{}, {}, ChallengeBody>, res: Response) => { + try { + const ip = extractClientIp(req); + const rateResult = takeChallengeToken(ip); + + if (!rateResult.ok) { + res.setHeader("Retry-After", String(rateResult.retryAfter)); + return res.status(429).json({ + error: "rate limit exceeded", + retry_after_seconds: rateResult.retryAfter, + }); + } - const validation = validateStellarAddress(parsed.data.address); - if (!validation.valid) { - return res.status(400).json({ error: validation.error }); - } + const parsed = ChallengeRequestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid request body" }); + } - const address = validation.address; - const nonce = crypto.randomUUID(); - const challenge = buildChallenge(address, nonce); - const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS); + const validation = validateStellarAddress(parsed.data.address); + if (!validation.valid) { + return res.status(400).json({ error: validation.error }); + } - await prismaClient.$transaction(async (tx: any) => { - await tx.auth_challenges.deleteMany({ - where: { expires_at: { lte: new Date() } }, + const address = validation.address; + const nonce = crypto.randomUUID(); + const challenge = buildChallenge(address, nonce); + const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS); + + await prismaClient.$transaction(async (tx: any) => { + await tx.auth_challenges.deleteMany({ + where: { expires_at: { lte: new Date() } }, + }); + + await tx.auth_challenges.upsert({ + where: { address }, + update: { challenge, expires_at: expiresAt }, + create: { address, challenge, expires_at: expiresAt }, + }); }); - await tx.auth_challenges.upsert({ - where: { address }, - update: { challenge, expires_at: expiresAt }, - create: { address, challenge, expires_at: expiresAt }, - }); - }); - - return res.json({ challenge, expires_at: expiresAt.toISOString() }); - } catch (error) { - console.error("[auth/challenge]", error); - return res.status(500).json({ error: "Internal server error" }); - } - }); + return res.json({ challenge, expires_at: expiresAt.toISOString() }); + } catch (error) { + console.error("[auth/challenge]", error); + return res.status(500).json({ error: "Internal server error" }); + } + }, + ); r.post("/verify", async (req: Request<{}, {}, VerifyBody>, res: Response) => { try { @@ -1200,16 +1097,25 @@ export function createAuthRouter(deps: { } if (!isChallengeFresh(challengeRecord)) { - await prismaClient.auth_challenges.deleteMany({ - where: { address, challenge: challengeRecord.challenge }, - }).catch(() => {}); + await prismaClient.auth_challenges + .deleteMany({ + where: { address, challenge: challengeRecord.challenge }, + }) + .catch(() => {}); return res.status(401).json({ error: "Challenge expired" }); } - let isValid = verifyStellarSignature(address, challengeRecord.challenge, signature); + let isValid = verifyStellarSignature( + address, + challengeRecord.challenge, + signature, + ); if (!isValid && process.env.NODE_ENV !== "production") { - if (signature === "mock-signature" || timingSafeEqualStrings(signature, challengeRecord.challenge)) { + if ( + signature === "mock-signature" || + timingSafeEqualStrings(signature, challengeRecord.challenge) + ) { // Accept mock / self-signed challenges in dev/test isValid = true; } @@ -1235,8 +1141,13 @@ export function createAuthRouter(deps: { const accessToken = issueAccessToken(address, accessJti); const rawRefreshToken = crypto.randomBytes(48).toString("base64url"); - const hashedRefreshToken = crypto.createHash("sha256").update(rawRefreshToken).digest("hex"); - const refreshExpiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_SEC * 1000); + const hashedRefreshToken = crypto + .createHash("sha256") + .update(rawRefreshToken) + .digest("hex"); + const refreshExpiresAt = new Date( + Date.now() + REFRESH_TOKEN_TTL_SEC * 1000, + ); await prismaClient.refresh_tokens.create({ data: { @@ -1255,9 +1166,18 @@ export function createAuthRouter(deps: { data: { token: sessionToken, address, expires_at: sessionExpiresAt }, }); - res.cookie(ACCESS_TOKEN_COOKIE, accessToken, { ...COOKIE_BASE_OPTIONS, maxAge: ACCESS_TOKEN_TTL_SEC * 1000 }); - res.cookie(REFRESH_TOKEN_COOKIE, rawRefreshToken, { ...COOKIE_BASE_OPTIONS, maxAge: REFRESH_TOKEN_TTL_SEC * 1000 }); - res.cookie(SESSION_COOKIE_NAME, sessionToken, { ...COOKIE_BASE_OPTIONS, maxAge: SESSION_TTL_MS }); + res.cookie(ACCESS_TOKEN_COOKIE, accessToken, { + ...COOKIE_BASE_OPTIONS, + maxAge: ACCESS_TOKEN_TTL_SEC * 1000, + }); + res.cookie(REFRESH_TOKEN_COOKIE, rawRefreshToken, { + ...COOKIE_BASE_OPTIONS, + maxAge: REFRESH_TOKEN_TTL_SEC * 1000, + }); + res.cookie(SESSION_COOKIE_NAME, sessionToken, { + ...COOKIE_BASE_OPTIONS, + maxAge: SESSION_TTL_MS, + }); return res.status(200).json({ access_token: accessToken, @@ -1272,70 +1192,91 @@ export function createAuthRouter(deps: { } }); - r.post("/refresh", async (req: Request<{}, {}, RefreshBody>, res: Response) => { - try { - const parsed = RefreshRequestSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: "Invalid request body" }); - } + r.post( + "/refresh", + async (req: Request<{}, {}, RefreshBody>, res: Response) => { + try { + const parsed = RefreshRequestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Invalid request body" }); + } - let refreshToken = parsed.data.refresh_token; - if (!refreshToken) { - refreshToken = req.cookies?.[REFRESH_TOKEN_COOKIE]; - } - if (!refreshToken || typeof refreshToken !== "string") { - return res.status(400).json({ error: "refresh_token is required" }); - } + let refreshToken = parsed.data.refresh_token; + if (!refreshToken) { + refreshToken = req.cookies?.[REFRESH_TOKEN_COOKIE]; + } + if (!refreshToken || typeof refreshToken !== "string") { + return res.status(400).json({ error: "refresh_token is required" }); + } - const incomingHash = crypto.createHash("sha256").update(refreshToken).digest("hex"); - const record = await prismaClient.refresh_tokens.findUnique({ where: { token_hash: incomingHash } }); + const incomingHash = crypto + .createHash("sha256") + .update(refreshToken) + .digest("hex"); + const record = await prismaClient.refresh_tokens.findUnique({ + where: { token_hash: incomingHash }, + }); - if (!record) { - return res.status(401).json({ error: "Invalid refresh token" }); - } - if (record.revoked) { - return res.status(401).json({ error: "Refresh token has been revoked" }); - } - if (record.expires_at.getTime() <= Date.now()) { - return res.status(401).json({ error: "Refresh token expired" }); - } + if (!record) { + return res.status(401).json({ error: "Invalid refresh token" }); + } + if (record.revoked) { + return res + .status(401) + .json({ error: "Refresh token has been revoked" }); + } + if (record.expires_at.getTime() <= Date.now()) { + return res.status(401).json({ error: "Refresh token expired" }); + } - const newAccessJti = crypto.randomUUID(); - const newAccessToken = issueAccessToken(record.address, newAccessJti); + const newAccessJti = crypto.randomUUID(); + const newAccessToken = issueAccessToken(record.address, newAccessJti); - await prismaClient.refresh_tokens.update({ - where: { id: record.id }, - data: { revoked: true, revoked_at: new Date() }, - }); + await prismaClient.refresh_tokens.update({ + where: { id: record.id }, + data: { revoked: true, revoked_at: new Date() }, + }); - const rawNewRefreshToken = crypto.randomBytes(48).toString("base64url"); - const hashedNewRefresh = crypto.createHash("sha256").update(rawNewRefreshToken).digest("hex"); - const newRefreshExpiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_SEC * 1000); + const rawNewRefreshToken = crypto.randomBytes(48).toString("base64url"); + const hashedNewRefresh = crypto + .createHash("sha256") + .update(rawNewRefreshToken) + .digest("hex"); + const newRefreshExpiresAt = new Date( + Date.now() + REFRESH_TOKEN_TTL_SEC * 1000, + ); - await prismaClient.refresh_tokens.create({ - data: { - token_hash: hashedNewRefresh, - address: record.address, - expires_at: newRefreshExpiresAt, - revoked: false, - jti: crypto.randomUUID(), - }, - }); + await prismaClient.refresh_tokens.create({ + data: { + token_hash: hashedNewRefresh, + address: record.address, + expires_at: newRefreshExpiresAt, + revoked: false, + jti: crypto.randomUUID(), + }, + }); - res.cookie(ACCESS_TOKEN_COOKIE, newAccessToken, { ...COOKIE_BASE_OPTIONS, maxAge: ACCESS_TOKEN_TTL_SEC * 1000 }); - res.cookie(REFRESH_TOKEN_COOKIE, rawNewRefreshToken, { ...COOKIE_BASE_OPTIONS, maxAge: REFRESH_TOKEN_TTL_SEC * 1000 }); + res.cookie(ACCESS_TOKEN_COOKIE, newAccessToken, { + ...COOKIE_BASE_OPTIONS, + maxAge: ACCESS_TOKEN_TTL_SEC * 1000, + }); + res.cookie(REFRESH_TOKEN_COOKIE, rawNewRefreshToken, { + ...COOKIE_BASE_OPTIONS, + maxAge: REFRESH_TOKEN_TTL_SEC * 1000, + }); - return res.status(200).json({ - access_token: newAccessToken, - refresh_token: rawNewRefreshToken, - token_type: "Bearer", - expires_in: ACCESS_TOKEN_TTL_SEC, - }); - } catch (error) { - console.error("[auth/refresh]", error); - return res.status(500).json({ error: "Internal server error" }); - } - }); + return res.status(200).json({ + access_token: newAccessToken, + refresh_token: rawNewRefreshToken, + token_type: "Bearer", + expires_in: ACCESS_TOKEN_TTL_SEC, + }); + } catch (error) { + console.error("[auth/refresh]", error); + return res.status(500).json({ error: "Internal server error" }); + } + }, + ); r.post("/logout", async (req: Request, res: Response) => { try { @@ -1361,9 +1302,18 @@ export function createAuthRouter(deps: { }) as JwtPayload; if (decoded.jti && decoded.exp) { - const ttlSeconds = Math.max(1, decoded.exp - Math.floor(Date.now() / 1000)); + const ttlSeconds = Math.max( + 1, + decoded.exp - Math.floor(Date.now() / 1000), + ); if (depsRedis) { - await depsRedis.set(`${BLACKLIST_NS}${decoded.jti}`, "1", "EX", ttlSeconds, "NX"); + await depsRedis.set( + `${BLACKLIST_NS}${decoded.jti}`, + "1", + "EX", + ttlSeconds, + "NX", + ); } } } catch { @@ -1373,11 +1323,16 @@ export function createAuthRouter(deps: { } if (refreshToken && typeof refreshToken === "string") { - const hash = crypto.createHash("sha256").update(refreshToken).digest("hex"); - await prismaClient.refresh_tokens.updateMany({ - where: { token_hash: hash, revoked: false }, - data: { revoked: true, revoked_at: new Date() }, - }).catch(() => {}); + const hash = crypto + .createHash("sha256") + .update(refreshToken) + .digest("hex"); + await prismaClient.refresh_tokens + .updateMany({ + where: { token_hash: hash, revoked: false }, + data: { revoked: true, revoked_at: new Date() }, + }) + .catch(() => {}); } const sessionToken = extractBearerToken(req); @@ -1387,7 +1342,7 @@ export function createAuthRouter(deps: { "1", "EX", REFRESH_TOKEN_TTL_SEC, - "NX" + "NX", ); } @@ -1409,21 +1364,28 @@ export function createAuthRouter(deps: { return res.status(401).json({ error: "Session token is required" }); } - if (await isSessionRevoked(depsRedis, token)) { + if (depsRedis && (await isSessionRevoked(depsRedis, token))) { return res.status(401).json({ error: "Session has been revoked" }); } const now = new Date(); - const session = await prismaClient.sessions.findUnique({ where: { token } }); + const session = await prismaClient.sessions.findUnique({ + where: { token }, + }); if (!session || session.expires_at <= now) { if (session) { - await prismaClient.sessions.deleteMany({ where: { expires_at: { lte: now } } }); + await prismaClient.sessions.deleteMany({ + where: { expires_at: { lte: now } }, + }); } return res.status(401).json({ error: "Session expired or not found" }); } - return res.json({ address: session.address, expires_at: session.expires_at.toISOString() }); + return res.json({ + address: session.address, + expires_at: session.expires_at.toISOString(), + }); } catch (error) { console.error("[auth/session]", error); return res.status(500).json({ error: "Internal server error" }); 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/auth.test.ts b/backend/tests/auth.test.ts index 54835a93..340a3856 100644 --- a/backend/tests/auth.test.ts +++ b/backend/tests/auth.test.ts @@ -2,8 +2,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import Module from "node:module"; import crypto from "node:crypto"; +import jwt from "jsonwebtoken"; import { Keypair } from "@stellar/stellar-sdk"; +process.env.JWT_SECRET = "test-secret-minimum-32-characters!!"; + const originalLoad = (Module as any)._load; (Module as any)._load = function patchedLoad(request: string, parent: unknown, isMain: boolean) { if (request === "../config/db") { @@ -14,13 +17,30 @@ const originalLoad = (Module as any)._load; const auth = require("../src/routes/auth") as typeof import("../src/routes/auth"); +test("normalizeStellarAddress mirrors sanitizeStellarAddress", () => { + const keypair = Keypair.random(); + const address = keypair.publicKey(); + + assert.equal(auth.normalizeStellarAddress(address), address); + // normalizeStellarAddress uppercases input, so lowercase is normalized + assert.equal(auth.normalizeStellarAddress(address.toLowerCase()), address); + assert.equal(auth.normalizeStellarAddress(`${address.slice(0, -1)}A`), null); +}); + +test("isChallengeExpired returns true for past dates and false for future dates", () => { + assert.equal(auth.isChallengeExpired(new Date(Date.now() - 1_000)), true); + assert.equal(auth.isChallengeExpired(new Date(Date.now() + 60_000)), false); +}); + test("sanitizes Stellar addresses by enforcing canonical StrKey checksums", () => { const keypair = Keypair.random(); const address = keypair.publicKey(); assert.equal(auth.sanitizeStellarAddress(address), address); - assert.equal(auth.sanitizeStellarAddress(address.toLowerCase()), null); - assert.equal(auth.sanitizeStellarAddress(` ${address}`), null); + // sanitizeStellarAddress uppercases input, so lowercase is normalized + assert.equal(auth.sanitizeStellarAddress(address.toLowerCase()), address); + // Whitespace trimming is part of normalization — spaces are accepted + assert.equal(auth.sanitizeStellarAddress(` ${address}`), address); assert.equal(auth.sanitizeStellarAddress(`${address.slice(0, -1)}A`), null); }); @@ -71,6 +91,9 @@ test("auth router returns 401 for bad signatures and consumes valid challenges o const sessions = new Map(); let storedRecord: typeof record | null = record; + const refreshTokens = new Map(); + let rtId = 1; + const app = express(); app.use(express.json()); app.use("/auth", auth.createAuthRouter({ @@ -149,6 +172,244 @@ test("auth router returns 401 for bad signatures and consumes valid challenges o assert.equal(replayResponse.status, 401); assert.equal(sessions.size, 1); } finally { - await new Promise((resolve, reject) => server.close((error?: Error) => error ? reject(error) : resolve())); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test("admin override succeeds with valid JWT admin role and correct Stellar signature", async () => { + const keypair = Keypair.random(); + const adminAddress = keypair.publicKey(); + const disputeId = "test-dispute-uuid"; + const verdictRecord = { id: "v1", dispute_id: disputeId, winner: "freelancer", freelancer_share_bps: 5000, reasoning: "", created_at: new Date() }; + + const express = require("express") as typeof import("express"); + const app = express(); + app.use(express.json()); + app.use("/auth", auth.createAuthRouter({ + prismaClient: { + $transaction: async (fn: any) => fn({ auth_challenges: { deleteMany: async () => ({ count: 0 }), upsert: async () => ({}) } }), + auth_challenges: { findUnique: async () => null, deleteMany: async () => ({ count: 0 }), upsert: async () => ({}) }, + refresh_tokens: { create: async () => ({}), findUnique: async () => null, update: async () => ({}) }, + sessions: { create: async () => ({}), findUnique: async () => null, deleteMany: async () => ({ count: 0 }) }, + arbiters: { + findUnique: async ({ where }: any) => { + if (where.address === adminAddress) return { address: adminAddress, active: true }; + return null; + }, + }, + disputes: { + findUnique: async ({ where }: any) => { + if (where.id === disputeId) return { id: disputeId, job_id: "j1", opened_by: "client", status: "open" }; + return null; + }, + }, + verdicts: { + create: async ({ data }: any) => ({ ...verdictRecord, ...data }), + }, + }, + redisClient: null, + })); + + const token = jwt.sign({ address: adminAddress, role: "admin", jti: crypto.randomUUID() }, process.env.JWT_SECRET!, { + issuer: "lance-marketplace", audience: "lance-frontend", expiresIn: "15m", + }); + + // Build the expected override message (same format as in auth.ts) + const overrideMessage = [ + "Lance Admin Dispute Override:", + `Dispute: ${disputeId}`, + "Winner: freelancer", + "Freelancer Share Basis Points: 5000", + `Admin: ${adminAddress}`, + ].join("\n"); + + // Sign the override message hash (SEP-53 style) + const digest = crypto.createHash("sha256").update(Buffer.from("Stellar Signed Message:\n" + overrideMessage, "utf8")).digest(); + const overrideSignature = keypair.sign(digest).toString("base64"); + + const server = app.listen(0); + const baseUrl = `http://127.0.0.1:${(server.address() as any).port}/auth`; + + try { + const res = await fetch(`${baseUrl}/admin/dispute/${disputeId}/override`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ winner: "freelancer", freelancer_share_bps: 5000, signature: overrideSignature }), + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.message, "Dispute verdict overridden by admin"); + assert.equal(body.verdict.winner, "freelancer"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +// ============================================================================= +// Admin dispute override (BE-W3A-108) +// ============================================================================= + +test("admin override rejects request without authorized JWT", async () => { + const express = require("express") as typeof import("express"); + const app = express(); + app.use(express.json()); + app.use("/auth", auth.createAuthRouter({ + prismaClient: { + $transaction: async (fn: any) => fn({ auth_challenges: { deleteMany: async () => ({ count: 0 }), upsert: async () => ({}) } }), + auth_challenges: { findUnique: async () => null, deleteMany: async () => ({ count: 0 }), upsert: async () => ({}) }, + refresh_tokens: { create: async () => ({}), findUnique: async () => null, update: async () => ({}) }, + sessions: { create: async () => ({}), findUnique: async () => null, deleteMany: async () => ({ count: 0 }) }, + }, + redisClient: null, + })); + + const server = app.listen(0); + const port = (server.address() as any).port; + + try { + const res = await fetch(`http://127.0.0.1:${port}/auth/admin/dispute/abc/override`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ winner: "freelancer", freelancer_share_bps: 5000, signature: "test" }), + }); + assert.equal(res.status, 401); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test("admin override rejects valid JWT without admin role", async () => { + process.env.JWT_SECRET = "test-secret-minimum-32-characters!!"; + const express = require("express") as typeof import("express"); + const app = express(); + app.use(express.json()); + app.use("/auth", auth.createAuthRouter({ + prismaClient: { + $transaction: async (fn: any) => fn({ auth_challenges: { deleteMany: async () => ({ count: 0 }), upsert: async () => ({}) } }), + auth_challenges: { findUnique: async () => null, deleteMany: async () => ({ count: 0 }), upsert: async () => ({}) }, + refresh_tokens: { create: async () => ({}), findUnique: async () => null, update: async () => ({}) }, + sessions: { create: async () => ({}), findUnique: async () => null, deleteMany: async () => ({ count: 0 }) }, + }, + redisClient: null, + })); + + const secret = process.env.JWT_SECRET!; + const token = jwt.sign({ address: Keypair.random().publicKey(), role: "freelancer", jti: crypto.randomUUID() }, secret, { + issuer: "lance-marketplace", audience: "lance-frontend", expiresIn: "15m", + }); + + const server = app.listen(0); + const baseUrl = `http://127.0.0.1:${(server.address() as any).port}/auth`; + + try { + const res = await fetch(`${baseUrl}/admin/dispute/abc/override`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ winner: "freelancer", freelancer_share_bps: 5000, signature: "test" }), + }); + assert.equal(res.status, 403); + const body = await res.json(); + assert.equal(body.error, "Insufficient permissions"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test("admin override rejects invalid Stellar signature on override message", async () => { + const secret = process.env.JWT_SECRET!; + const keypair = Keypair.random(); + const adminAddress = keypair.publicKey(); + + const express = require("express") as typeof import("express"); + const app = express(); + app.use(express.json()); + app.use("/auth", auth.createAuthRouter({ + prismaClient: { + $transaction: async (fn: any) => fn({ auth_challenges: { deleteMany: async () => ({ count: 0 }), upsert: async () => ({}) } }), + auth_challenges: { findUnique: async () => null, deleteMany: async () => ({ count: 0 }), upsert: async () => ({}) }, + refresh_tokens: { create: async () => ({}), findUnique: async () => null, update: async () => ({}) }, + sessions: { create: async () => ({}), findUnique: async () => null, deleteMany: async () => ({ count: 0 }) }, + arbiters: { + findUnique: async ({ where }: any) => { + if (where.address === adminAddress) return { address: adminAddress, active: true }; + return null; + }, + }, + }, + redisClient: null, + })); + + const token = jwt.sign({ address: adminAddress, role: "admin", jti: crypto.randomUUID() }, secret, { + issuer: "lance-marketplace", audience: "lance-frontend", expiresIn: "15m", + }); + + const server = app.listen(0); + const baseUrl = `http://127.0.0.1:${(server.address() as any).port}/auth`; + + try { + // Wrong signature (doesn't match override message) + const res = await fetch(`${baseUrl}/admin/dispute/abc/override`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ winner: "freelancer", freelancer_share_bps: 5000, signature: Buffer.from("bad").toString("base64") }), + }); + assert.equal(res.status, 401); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +test("admin override succeeds with valid JWT admin role and correct Stellar signature", async () => { + const secret = process.env.JWT_SECRET!; + const keypair = Keypair.random(); + const adminAddress = keypair.publicKey(); + const disputeId = "test-dispute-uuid"; + const verdictRecord = { id: "v1", dispute_id: disputeId, winner: "freelancer", freelancer_share_bps: 5000, reasoning: "", created_at: new Date() }; + + const express = require("express") as typeof import("express"); + const app = express(); + app.use(express.json()); + app.use("/auth", auth.createAuthRouter({ + prismaClient: { + $transaction: async (fn: any) => fn({ auth_challenges: { deleteMany: async () => ({ count: 0 }), upsert: async () => ({}) } }), + auth_challenges: { findUnique: async () => null, deleteMany: async () => ({ count: 0 }), upsert: async () => ({}) }, + refresh_tokens: { create: async () => ({}), findUnique: async () => null, update: async () => ({}) }, + sessions: { create: async () => ({}), findUnique: async () => null, deleteMany: async () => ({ count: 0 }) }, + arbiters: { + findUnique: async ({ where }: any) => { + if (where.address === adminAddress) return { address: adminAddress, active: true }; + return null; + }, + }, + disputes: { + findUnique: async ({ where }: any) => { + if (where.id === disputeId) return { id: disputeId, job_id: "j1", opened_by: "client", status: "open" }; + return null; + }, + }, + verdicts: { + create: async ({ data }: any) => ({ ...verdictRecord, ...data }), + }, + }, + redisClient: null, + })); + + const token = jwt.sign({ address: adminAddress, role: "admin", jti: crypto.randomUUID() }, secret, { + issuer: "lance-marketplace", audience: "lance-frontend", expiresIn: "15m", + }); + + const server = app.listen(0); + const baseUrl = `http://127.0.0.1:${(server.address() as any).port}/auth`; + + try { + // Wrong signature (doesn't match override message) + const res = await fetch(`${baseUrl}/admin/dispute/abc/override`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ winner: "freelancer", freelancer_share_bps: 5000, signature: Buffer.from("bad").toString("base64") }), + }); + assert.equal(res.status, 401); + } finally { + await new Promise((resolve) => server.close(() => resolve())); } }); 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(); +});