From 38e58480a5c441ef3deeda5f91d70b75ff8f3fd8 Mon Sep 17 00:00:00 2001 From: Douglas Francis Date: Fri, 29 May 2026 14:12:52 +0100 Subject: [PATCH 1/3] feat(auth): integrate Redis token blacklisting and lifecycle hooks with verification suites --- backend/package.json | 4 +- backend/src/index.ts | 22 +++++++-- backend/test/run-tests.ts | 96 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 backend/test/run-tests.ts diff --git a/backend/package.json b/backend/package.json index c8356698..866ab005 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,7 @@ "dev": "nodemon src/index.ts", "build": "tsc", "start": "node dist/index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "ts-node test/run-tests.ts" }, "keywords": [], "author": "", @@ -47,4 +47,4 @@ "ts-node": "^10.9.2", "typescript": "^6.0.3" } -} +} \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 207d68b4..c5ca2c04 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,6 +4,7 @@ import cookieParser from "cookie-parser"; import crypto from "crypto"; import dotenv from "dotenv"; import { prisma, connectWithRetry, startPoolHealthCheck } from "./config/db"; +import { redis } from "./config/redis"; import { trace } from "./config/tracing"; import { intakeRateLimit } from "./middleware/intakeRateLimit"; import { sqlInjectionGuard } from "./middleware/sanitize"; @@ -42,9 +43,9 @@ app.use(express.json()); // CSRF protection middleware (double-submit cookie pattern) const csrfMiddleware = (req: Request, res: Response, next: NextFunction) => { // Skip CSRF for GET/HEAD/OPTIONS and auth challenge/verify routes - if (["GET", "HEAD", "OPTIONS"].includes(req.method) || - (req.path.startsWith("/api/v1/auth/") && - (req.path.endsWith("/challenge") || req.path.endsWith("/verify")))) { + if (["GET", "HEAD", "OPTIONS"].includes(req.method) || + (req.path.startsWith("/api/v1/auth/") && + (req.path.endsWith("/challenge") || req.path.endsWith("/verify")))) { return next(); } @@ -62,7 +63,7 @@ const csrfMiddleware = (req: Request, res: Response, next: NextFunction) => { // Route to get CSRF token app.get("/api/v1/auth/csrf", (req: Request, res: Response) => { const csrfToken = crypto.randomBytes(32).toString("hex"); - + // Set CSRF cookie (HttpOnly false so frontend can read it, SameSite strict) res.cookie(CSRF_COOKIE_NAME, csrfToken, { httpOnly: false, @@ -161,6 +162,12 @@ process.on("SIGTERM", async () => { stopStorageCleanup(); try { await prisma.$disconnect(); + try { + await redis.quit(); + logger.info("Redis connection closed"); + } catch (err: any) { + logger.debug("Error closing Redis on shutdown:", err?.message || err); + } logger.info("Database connection closed"); process.exit(0); } catch (error) { @@ -178,6 +185,13 @@ process.on("SIGTERM", async () => { async function bootstrap(): Promise { try { await connectWithRetry(); + // Ensure Redis client is connected (lazyConnect enabled in config) + try { + await redis.connect(); + console.log("[redis] connected (bootstrap)"); + } catch (err: any) { + console.warn("[redis] could not connect at startup:", err?.message || err); + } startPoolHealthCheck(); startStorageCleanup(); app.listen(port, () => { diff --git a/backend/test/run-tests.ts b/backend/test/run-tests.ts new file mode 100644 index 00000000..c8a8f55e --- /dev/null +++ b/backend/test/run-tests.ts @@ -0,0 +1,96 @@ +import assert from "assert"; +import jwt from "jsonwebtoken"; +import crypto from "crypto"; + +// Ensure ts-node can run this file even if project modules expect env vars +process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret"; + +// Lightweight mock Redis implementation +class MockRedis { + store: Map = new Map(); + lastSetArgs: any = null; + async connect() { } + async quit() { } + async set(key: string, value: string, ...args: any[]) { + // Parse args for EX and NX + let exSeconds: number | undefined = undefined; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "EX") { + exSeconds = Number(args[i + 1]); + } + } + const expiresAt = exSeconds ? Math.floor(Date.now() / 1000) + exSeconds : undefined; + this.store.set(key, { value, expiresAt }); + this.lastSetArgs = { key, value, exSeconds, args }; + return "OK"; + } + async get(key: string) { + const v = this.store.get(key); + if (!v) return null; + if (v.expiresAt && v.expiresAt < Math.floor(Date.now() / 1000)) { + this.store.delete(key); + return null; + } + return v.value; + } +} + +async function main() { + // Replace the real Redis client with our mock before importing modules + const redisModulePath = "../src/config/redis"; + const redisModule = require(redisModulePath); + const mock = new MockRedis(); + redisModule.redis = mock; + + const auth = require("../src/routes/auth"); + const authGuardModule = require("../src/middleware/authGuard"); + + // Test 1: blacklistToken stores the key and isTokenBlacklisted reads it + const jti = "test-jti-1"; + const expiresAt = Math.floor(Date.now() / 1000) + 60; // expires in 60s + await auth.blacklistToken(jti, expiresAt); + assert(mock.lastSetArgs, "redis.set should have been called"); + assert.strictEqual(mock.lastSetArgs.key, `jwt:blacklist:${jti}`); + + const blacklisted = await auth.isTokenBlacklisted(jti); + assert.strictEqual(blacklisted, true, "Token should be reported blacklisted"); + + // Test 2: TTL matches remaining seconds approximately + const recordedTtl = mock.lastSetArgs.exSeconds; + const expected = expiresAt - Math.floor(Date.now() / 1000); + assert(Math.abs(recordedTtl - expected) <= 2, `TTL should match remaining lifetime (got ${recordedTtl}, expected ${expected})`); + + // Test 3: authGuard rejects a request when token jti is blacklisted + // Create an access token with the same jti and appropriate claims + const secret = process.env.JWT_SECRET as string; + const token = jwt.sign({ address: "GABCDEFGHIJKLMNOPQRSTUVWXYYYYYYYYYYYYYYYYYYYYY" }, secret, { + jwtid: jti, + subject: "GABCDEFGHIJKLMNOPQRSTUVWXYYYYYYYYYYYYYYYYYYYYY", + expiresIn: 60, // 1 minute + issuer: "lance-marketplace", + audience: "lance-frontend", + }); + + // Build fake req/res/next + const req: any = { headers: { authorization: `Bearer ${token}` }, cookies: {} }; + let statusSet: number | null = null; + let jsonBody: any = null; + const res: any = { + status(code: number) { statusSet = code; return this; }, + json(body: any) { jsonBody = body; return this; } + }; + let nextCalled = false; + const next = () => { nextCalled = true; }; + + await authGuardModule.authGuard(req, res, next); + assert.strictEqual(statusSet, 401, "authGuard should respond 401 for blacklisted token"); + assert(jsonBody && jsonBody.error && jsonBody.error.includes("revoked" || "revoked"), "Expected revoked message"); + + console.log("ALL TESTS PASSED"); +} + +main().catch((err) => { + console.error("Tests failed:", err); + process.exit(1); +}); From 3e48be28bd99b25b5ac48d62d792ceb28f9892d7 Mon Sep 17 00:00:00 2001 From: Douglas Francis Date: Fri, 29 May 2026 15:42:47 +0100 Subject: [PATCH 2/3] audit(auth): secure signature verification key decoders against malformed raw bytes --- backend/src/routes/auth.ts | 141 ++++++++++++++++++++++--------------- backend/test/run-tests.ts | 24 +++++++ 2 files changed, 109 insertions(+), 56 deletions(-) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 23733c66..dd29c735 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -78,13 +78,42 @@ function buildMessageHash(challenge: string): Buffer { * Wallets may deliver either encoding; we try hex first. */ function decodeSignature(raw: string): Buffer { + if (typeof raw !== "string") { + throw new Error("Signature must be a string"); + } + + // Trim common prefixes and whitespace + let s = raw.trim(); + if (s.startsWith("0x") || s.startsWith("0X")) { + s = s.slice(2); + } + + // Try hex first (even length required) const hexPattern = /^[0-9a-fA-F]+$/; - if (hexPattern.test(raw) && raw.length % 2 === 0) { - return Buffer.from(raw, "hex"); + if (hexPattern.test(s) && s.length % 2 === 0) { + try { + const buf = Buffer.from(s, "hex"); + if (buf.length !== 64) throw new Error("invalid-length"); + return buf; + } catch (e) { + throw new Error("Invalid signature hex encoding or length"); + } + } + + // Try base64 / base64url + try { + // Normalize base64url to base64 by replacing -_ chars + const normalized = s.replace(/-/g, "+").replace(/_/g, "/"); + const buf = Buffer.from(normalized, "base64"); + if (buf.length !== 64) throw new Error("Invalid signature length"); + return buf; + } catch (e) { + throw new Error("Invalid signature encoding or length"); } - return Buffer.from(raw, "base64"); } +export { decodeSignature }; + /** * Issues a signed JWT access token. * @@ -294,7 +323,7 @@ router.post( // Strict expiry check: reject if even one millisecond past deadline. if (record.expires_at.getTime() < Date.now()) { // Clean up expired record so it doesn't accumulate. - await prisma.auth_challenges.delete({ where: { address } }).catch(() => {}); + await prisma.auth_challenges.delete({ where: { address } }).catch(() => { }); return res.status(401).json({ error: "Challenge expired — please request a new one" }); } @@ -450,65 +479,65 @@ router.post( // --------------------------------------------------------------------------- router.post("/logout", async (req: Request, res: Response) => { - try { - // Try to get access token from cookie first, then header - let rawAccessToken = req.cookies[ACCESS_TOKEN_COOKIE]; - const authHeader = req.headers.authorization; - if (!rawAccessToken && authHeader?.startsWith("Bearer ")) { - rawAccessToken = authHeader.slice(7); - } - // Try to get refresh token from cookie first, then body - let refreshToken = req.cookies[REFRESH_TOKEN_COOKIE]; - const { refresh_token } = req.body as { refresh_token?: string }; - if (!refreshToken && refresh_token) { - refreshToken = refresh_token; - } + try { + // Try to get access token from cookie first, then header + let rawAccessToken = req.cookies[ACCESS_TOKEN_COOKIE]; + const authHeader = req.headers.authorization; + if (!rawAccessToken && authHeader?.startsWith("Bearer ")) { + rawAccessToken = authHeader.slice(7); + } + // Try to get refresh token from cookie first, then body + let refreshToken = req.cookies[REFRESH_TOKEN_COOKIE]; + const { refresh_token } = req.body as { refresh_token?: string }; + if (!refreshToken && refresh_token) { + refreshToken = refresh_token; + } - // ── Blacklist the access token ───────────────────────────────────── - if (rawAccessToken) { - const secret = process.env.JWT_SECRET; - - 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 { - // Expired / malformed tokens are silently ignored — we're logging out. + // ── Blacklist the access token ───────────────────────────────────── + if (rawAccessToken) { + const secret = process.env.JWT_SECRET; + + 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 { + // Expired / malformed tokens are silently ignored — we're logging out. } } + } - // ── Revoke the refresh token ─────────────────────────────────────── - if (refreshToken && typeof refreshToken === "string") { - const hash = crypto - .createHash("sha256") - .update(refreshToken) - .digest("hex"); - - await prisma.refresh_tokens - .updateMany({ - where: { token_hash: hash, revoked: false }, - data: { revoked: true }, - }) - .catch(() => {}); // Best-effort; missing record is not an error. - } - - // Clear cookies - res.clearCookie(ACCESS_TOKEN_COOKIE, COOKIE_BASE_OPTIONS); - res.clearCookie(REFRESH_TOKEN_COOKIE, COOKIE_BASE_OPTIONS); + // ── Revoke the refresh token ─────────────────────────────────────── + if (refreshToken && typeof refreshToken === "string") { + const hash = crypto + .createHash("sha256") + .update(refreshToken) + .digest("hex"); - return res.status(200).json({ message: "Logged out successfully" }); - } catch (error) { - console.error("[auth/logout] Unexpected error:", error); - return res.status(500).json({ error: "Internal server error" }); + await prisma.refresh_tokens + .updateMany({ + where: { token_hash: hash, revoked: false }, + data: { revoked: true }, + }) + .catch(() => { }); // Best-effort; missing record is not an error. } - }); + + // Clear cookies + res.clearCookie(ACCESS_TOKEN_COOKIE, COOKIE_BASE_OPTIONS); + res.clearCookie(REFRESH_TOKEN_COOKIE, COOKIE_BASE_OPTIONS); + + return res.status(200).json({ message: "Logged out successfully" }); + } catch (error) { + console.error("[auth/logout] Unexpected error:", error); + return res.status(500).json({ error: "Internal server error" }); + } +}); // --------------------------------------------------------------------------- // Utility exports — consumed by auth middleware in other routes diff --git a/backend/test/run-tests.ts b/backend/test/run-tests.ts index c8a8f55e..b8fb54fa 100644 --- a/backend/test/run-tests.ts +++ b/backend/test/run-tests.ts @@ -46,6 +46,30 @@ async function main() { const auth = require("../src/routes/auth"); const authGuardModule = require("../src/middleware/authGuard"); + // Additional tests: signature decoder robustness + // Ensure malformed signature inputs do not crash the process and are + // rejected with a controlled error. + try { + // invalid base64 / garbage should throw + let threw = false; + try { + // @ts-ignore + auth.decodeSignature("not-a-valid-base64!!!"); + } catch (e) { + threw = true; + } + assert(threw, "decodeSignature should throw on invalid input"); + + // valid 64-byte buffer encoded as base64 should decode + const validBuf = crypto.randomBytes(64); + const b64 = validBuf.toString("base64"); + const out = auth.decodeSignature(b64); + assert(out && out.length === 64, "decodeSignature should accept valid 64-byte base64"); + } catch (e) { + console.error("Signature decoder tests failed:", e); + throw e; + } + // Test 1: blacklistToken stores the key and isTokenBlacklisted reads it const jti = "test-jti-1"; const expiresAt = Math.floor(Date.now() / 1000) + 60; // expires in 60s From f3a120a6fc45b7765f2909b5ff97cc4e9d5635c7 Mon Sep 17 00:00:00 2001 From: Douglas Francis Date: Fri, 29 May 2026 15:49:54 +0100 Subject: [PATCH 3/3] Resolve merge: unify test script to use node with ts-node/register --- backend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/package.json b/backend/package.json index 866ab005..a3c18db5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,7 @@ "dev": "nodemon src/index.ts", "build": "tsc", "start": "node dist/index.js", - "test": "ts-node test/run-tests.ts" + "test": "node --require ts-node/register test/run-tests.ts" }, "keywords": [], "author": "",