Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "node --require ts-node/register test/run-tests.ts"
},
"keywords": [],
"author": "",
Expand Down Expand Up @@ -47,4 +47,4 @@
"ts-node": "^10.9.2",
"typescript": "^6.0.3"
}
}
}
22 changes: 18 additions & 4 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
}

Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -178,6 +185,13 @@ process.on("SIGTERM", async () => {
async function bootstrap(): Promise<void> {
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, () => {
Expand Down
141 changes: 85 additions & 56 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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" });
}

Expand Down Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions backend/test/run-tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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<string, { value: string; expiresAt?: number }> = new Map();
lastSetArgs: any = null;
async connect() { }
async quit() { }
async set(key: string, value: string, ...args: any[]) {
// Parse args for EX <seconds> 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");

// 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
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);
});