diff --git a/backend/package.json b/backend/package.json index c8356698..08c88d45 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": "node --require ts-node/register --test tests/**/*.test.ts" }, "keywords": [], "author": "", diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 23733c66..6f1f5ac3 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,517 +1,722 @@ /** * auth.ts — Secure JWT Session + Refresh Token Flow - * - * Implements: - * BE-W3A-105 — JWT session + refresh token issuance / rotation - * BE-W3A-102 — Strict 5-minute challenge expiry with automated cleanup - * - * Security guarantees: - * • Nonce-bound challenges expire in exactly 5 minutes (enforced both in DB - * and at verify-time so clock skew cannot be exploited). - * • Replay protection: each challenge is deleted atomically on first use. - * • Freighter SEP-53 signing prefix applied before SHA-256 hash. - * • Access tokens are short-lived (15 min); refresh tokens are long-lived - * (7 days) and stored hashed in the DB so a leaked DB row cannot be - * replayed directly. - * • Refresh token rotation: every /auth/refresh call issues a *new* refresh - * token and invalidates the old one (prevents refresh token reuse attacks). - * • Redis blacklist: revoked tokens are tombstoned with TTL equal to the - * remaining token lifetime so lookups are O(1) and self-expiring. - * • All timing-sensitive comparisons use `timingSafeEqual` to defeat - * timing-oracle attacks. */ import { Router, Request, Response } from "express"; import crypto from "crypto"; import jwt, { SignOptions, JwtPayload } from "jsonwebtoken"; -import { Keypair } from "@stellar/stellar-sdk"; +import { z } from "zod"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; + import { prisma } from "../config/db"; import { redis } from "../config/redis"; -// Cookie configuration constants -const isProduction = process.env.NODE_ENV === "production"; -const ACCESS_TOKEN_COOKIE = "lance_access_token"; -const REFRESH_TOKEN_COOKIE = "lance_refresh_token"; -const COOKIE_BASE_OPTIONS = { - httpOnly: true, - secure: isProduction, - sameSite: isProduction ? "strict" : "lax", - path: "/", -} as const; - const router = Router(); // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- -/** Challenge validity window in milliseconds (5 minutes). */ const CHALLENGE_TTL_MS = 5 * 60 * 1000; -/** Access token lifetime. Short to limit blast radius if stolen. */ -const ACCESS_TOKEN_TTL_SEC = 15 * 60; // 15 minutes +const ACCESS_TOKEN_TTL_SEC = 15 * 60; +const REFRESH_TOKEN_TTL_SEC = 7 * 24 * 60 * 60; -/** Refresh token lifetime. */ -const REFRESH_TOKEN_TTL_SEC = 7 * 24 * 60 * 60; // 7 days - -/** Prefix injected by Freighter / stellar-wallets-kit before signing. */ const STELLAR_SIGN_PREFIX = "Stellar Signed Message:\n"; -/** Redis key namespace for the revocation blacklist. */ const BLACKLIST_NS = "jwt:blacklist:"; +const ACCESS_TOKEN_COOKIE = "lance_access_token"; +const REFRESH_TOKEN_COOKIE = "lance_refresh_token"; + +const isProduction = process.env.NODE_ENV === "production"; + +const COOKIE_BASE_OPTIONS = { + httpOnly: true, + secure: isProduction, + sameSite: isProduction ? "strict" : "lax", + path: "/", +} as const; + +// --------------------------------------------------------------------------- +// Validation Schemas +// --------------------------------------------------------------------------- + +const ChallengeRequestSchema = z.object({ + address: z.string().min(1).max(128), +}); + +const VerifyRequestSchema = z.object({ + address: z.string().min(1).max(128), + signature: z.union([ + z.string().min(1).max(1024), + z.object({ + signature: z.string().min(1).max(1024), + }), + ]), +}); + +const RefreshRequestSchema = z.object({ + refresh_token: z.string().optional(), +}); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/** - * Derives the SHA-256 hash of the prefixed challenge exactly as Freighter - * would before producing the Ed25519 signature (SEP-53 compliant). - */ +function sanitizeStellarAddress(rawAddress: unknown): string | null { + if (typeof rawAddress !== "string") { + return null; + } + + const address = rawAddress.trim(); + + if (!/^G[A-Z2-7]{55}$/.test(address)) { + return null; + } + + try { + const decoded = StrKey.decodeEd25519PublicKey(address); + + if ( + decoded.length !== 32 || + !StrKey.isValidEd25519PublicKey(address) + ) { + return null; + } + + return StrKey.encodeEd25519PublicKey(decoded) === address + ? address + : null; + } catch { + return null; + } +} + function buildMessageHash(challenge: string): Buffer { - const payload = Buffer.from(STELLAR_SIGN_PREFIX + challenge); - return crypto.createHash("sha256").update(payload).digest(); + const payload = Buffer.from( + STELLAR_SIGN_PREFIX + challenge, + "utf8" + ); + + return crypto.createHash("sha256").update(payload).digest(); } -/** - * Normalises a hex or base64 signature string into a Buffer. - * Wallets may deliver either encoding; we try hex first. - */ function decodeSignature(raw: string): Buffer { - const hexPattern = /^[0-9a-fA-F]+$/; - if (hexPattern.test(raw) && raw.length % 2 === 0) { - return Buffer.from(raw, "hex"); - } - return Buffer.from(raw, "base64"); + const trimmed = raw.trim(); + + const hexPattern = /^[0-9a-fA-F]+$/; + + if (hexPattern.test(trimmed) && trimmed.length % 2 === 0) { + return Buffer.from(trimmed, "hex"); + } + + return Buffer.from(trimmed, "base64"); +} + +function timingSafeEqualStrings(a: string, b: string): boolean { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + + if (aBuf.length !== bBuf.length) { + return false; + } + + return crypto.timingSafeEqual(aBuf, bBuf); } -/** - * Issues a signed JWT access token. - * - * @param address — Stellar public key (G…) used as the `sub` claim. - * @param jti — Unique token ID used for blacklisting. - */ function issueAccessToken(address: string, jti: string): string { - const secret = process.env.JWT_SECRET; - if (!secret) throw new Error("JWT_SECRET environment variable is not set"); - - const options: SignOptions = { - subject: address, - jwtid: jti, - expiresIn: ACCESS_TOKEN_TTL_SEC, - issuer: "lance-marketplace", - audience: "lance-frontend", - }; - - return jwt.sign({ address }, secret, options); + const secret = process.env.JWT_SECRET; + + if (!secret) { + throw new Error("JWT_SECRET environment variable is not set"); + } + + const options: SignOptions = { + subject: address, + jwtid: jti, + expiresIn: ACCESS_TOKEN_TTL_SEC, + issuer: "lance-marketplace", + audience: "lance-frontend", + }; + + return jwt.sign({ address }, secret, options); } -/** - * Issues a refresh token — a cryptographically random UUID — and persists a - * SHA-256 hash of it to the database so the raw value never sits in storage. - * - * @returns { rawToken, hashedToken } - */ async function issueRefreshToken( - address: string, - previousTokenId?: number + address: string, + previousTokenId?: number ): Promise<{ rawToken: string; hashedToken: string }> { - // Invalidate previous refresh token before creating the new one (rotation). - if (previousTokenId !== undefined) { - await prisma.refresh_tokens.update({ - where: { id: previousTokenId }, - data: { revoked: true }, - }); - } - - const rawToken = crypto.randomBytes(48).toString("base64url"); // 384 bits - const hashedToken = crypto - .createHash("sha256") - .update(rawToken) - .digest("hex"); - - const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_SEC * 1000); - - await prisma.refresh_tokens.create({ - data: { - token_hash: hashedToken, - address, - expires_at: expiresAt, - revoked: false, - }, - }); - - return { rawToken, hashedToken }; + if (previousTokenId !== undefined) { + await prisma.refresh_tokens.update({ + where: { + id: previousTokenId, + }, + data: { + revoked: true, + }, + }); + } + + const rawToken = crypto.randomBytes(48).toString("base64url"); + + const hashedToken = crypto + .createHash("sha256") + .update(rawToken) + .digest("hex"); + + const expiresAt = new Date( + Date.now() + REFRESH_TOKEN_TTL_SEC * 1000 + ); + + await prisma.refresh_tokens.create({ + data: { + token_hash: hashedToken, + address, + expires_at: expiresAt, + revoked: false, + }, + }); + + return { + rawToken, + hashedToken, + }; } -/** - * Adds a JWT `jti` to the Redis blacklist with a TTL equal to the remaining - * token lifetime so the entry self-expires and memory is not leaked. - * - * Target latency: <1 ms (single SET NX EX command — no round-trip needed for - * existence check). - */ -async function blacklistToken(jti: string, expiresAt: number): Promise { - const ttlSeconds = Math.max(1, expiresAt - Math.floor(Date.now() / 1000)); - // SET key 1 EX ttl NX — atomic, no overwrite risk. - await redis.set(`${BLACKLIST_NS}${jti}`, "1", "EX", ttlSeconds, "NX"); +async function blacklistToken( + jti: string, + expiresAt: number +): Promise { + const ttlSeconds = Math.max( + 1, + expiresAt - Math.floor(Date.now() / 1000) + ); + + await redis.set( + `${BLACKLIST_NS}${jti}`, + "1", + "EX", + ttlSeconds, + "NX" + ); } -/** - * Returns `true` if the token's `jti` appears in the revocation blacklist. - * This is the hot path — a single Redis GET kept well under 1 ms in practice. - */ -async function isTokenBlacklisted(jti: string): Promise { - const result = await redis.get(`${BLACKLIST_NS}${jti}`); - return result !== null; +async function isTokenBlacklisted( + jti: string +): Promise { + const result = await redis.get(`${BLACKLIST_NS}${jti}`); + + return result !== null; } // --------------------------------------------------------------------------- -// Route: POST /api/v1/auth/challenge -// -// BE-W3A-102 — Generates a nonce-bound challenge and records a strict -// expiration timestamp. Any verify request arriving after CHALLENGE_TTL_MS -// will be rejected. +// Route: POST /challenge // --------------------------------------------------------------------------- interface ChallengeBody { - address: string; + address: string; } router.post( - "/challenge", - async (req: Request<{}, {}, ChallengeBody>, res: Response) => { - try { - const { address } = req.body; - - // ── Input validation ────────────────────────────────────────────────── - if (!address || typeof address !== "string") { - return res.status(400).json({ error: "address is required" }); - } - - // Reject obviously malformed Stellar addresses early (G + 55 base32 chars) - if (!/^G[A-Z2-7]{55}$/.test(address)) { - return res.status(400).json({ error: "Invalid Stellar address format" }); - } - - // Validate checksum by attempting to parse the address via the SDK. - // Keypair.fromPublicKey throws for invalid checksum bytes. - try { - Keypair.fromPublicKey(address); - } catch { - return res.status(400).json({ error: "Invalid Stellar address checksum" }); - } - - // ── Challenge generation ────────────────────────────────────────────── - const nonce = crypto.randomUUID(); - const issuedAt = new Date(); - const expiresAt = new Date(issuedAt.getTime() + CHALLENGE_TTL_MS); - - // Matches the human-readable EIP-4361-style format Freighter expects. - const challenge = - `Lance wants you to sign in with your Stellar account:\n` + - `${address}\n\n` + - `Nonce: ${nonce}\n` + - `Issued At: ${issuedAt.toISOString()}`; - - // Upsert so repeated challenge requests rotate the nonce rather than - // accumulating stale rows. - await prisma.auth_challenges.upsert({ - where: { address }, - update: { - challenge, - issued_at: issuedAt, - expires_at: expiresAt, - }, - create: { - address, - challenge, - issued_at: issuedAt, - expires_at: expiresAt, - }, - }); - - return res.status(200).json({ challenge }); - } catch (error) { - console.error("[auth/challenge] Unexpected error:", error); - return res.status(500).json({ error: "Internal server error" }); - } - } + "/challenge", + async ( + req: Request<{}, {}, ChallengeBody>, + res: Response + ) => { + try { + const parsed = + ChallengeRequestSchema.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", + }); + } + + const nonce = crypto.randomUUID(); + + const issuedAt = new Date(); + + const expiresAt = new Date( + issuedAt.getTime() + CHALLENGE_TTL_MS + ); + + const challenge = + `Lance wants you to sign in with your Stellar account:\n` + + `${address}\n\n` + + `Nonce: ${nonce}\n` + + `Issued At: ${issuedAt.toISOString()}`; + + await prisma.auth_challenges.upsert({ + where: { + address, + }, + update: { + challenge, + issued_at: issuedAt, + expires_at: expiresAt, + }, + create: { + address, + challenge, + issued_at: issuedAt, + expires_at: expiresAt, + }, + }); + + return res.status(200).json({ + challenge, + expires_at: expiresAt.toISOString(), + }); + } catch (error) { + console.error("[auth/challenge]", error); + + return res.status(500).json({ + error: "Internal server error", + }); + } + } ); // --------------------------------------------------------------------------- -// Route: POST /api/v1/auth/verify -// -// BE-W3A-105 — Verifies a Freighter/SEP-53 signature, then issues a JWT -// access token plus a refresh token. -// BE-W3A-102 — Enforces strict 5-minute challenge expiry. +// Route: POST /verify // --------------------------------------------------------------------------- interface VerifyBody { - address: string; - /** Raw Ed25519 signature in hex or base64, or the wrapped wallet-kit object. */ - signature: string | { signature: string }; + address: string; + signature: string | { signature: string }; } router.post( - "/verify", - async (req: Request<{}, {}, VerifyBody>, res: Response) => { - try { - const { address } = req.body; - let { signature } = req.body; - - // ── Input validation ────────────────────────────────────────────────── - if (!address || !signature) { - return res - .status(400) - .json({ error: "address and signature are required" }); - } - - if (!/^G[A-Z2-7]{55}$/.test(address)) { - return res.status(400).json({ error: "Invalid Stellar address format" }); - } - - // Unwrap wallet-kit object signatures. - if (typeof signature === "object" && "signature" in signature) { - signature = (signature as { signature: string }).signature; - } - - if (typeof signature !== "string" || signature.trim() === "") { - return res.status(400).json({ error: "Signature must be a non-empty string" }); - } - - // ── Challenge lookup & expiry enforcement (BE-W3A-102) ──────────────── - const record = await prisma.auth_challenges.findUnique({ - where: { address }, - }); - - if (!record) { - return res.status(404).json({ - error: "No pending challenge found — please request a new one", - }); - } - - // 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(() => {}); - return res.status(401).json({ error: "Challenge expired — please request a new one" }); - } - - // ── Signature verification (SEP-53 / Freighter) ─────────────────────── - let isValid = false; - - try { - const keypair = Keypair.fromPublicKey(address); - const sigBuffer = decodeSignature(signature); - const messageHash = buildMessageHash(record.challenge); - isValid = keypair.verify(messageHash, sigBuffer); - } catch (err) { - // Structural decode failures (bad encoding, bad public key) are treated - // as invalid signatures, not server errors. - console.warn("[auth/verify] Signature decode error:", err); - isValid = false; - } - - // Non-production fallback for E2E test suites that use a mock wallet. - if (!isValid && process.env.NODE_ENV !== "production") { - if (signature === "mock-signature" || signature === record.challenge) { - isValid = true; - } - } - - if (!isValid) { - return res.status(401).json({ error: "Invalid signature" }); - } - - // ── Atomic challenge deletion (replay prevention) ───────────────────── - // Delete *before* issuing tokens. If this fails the client must - // request a fresh challenge rather than reusing the current one. - await prisma.auth_challenges.delete({ where: { address } }); - - // ── Token issuance (BE-W3A-105) ─────────────────────────────────────── - const accessJti = crypto.randomUUID(); - const accessToken = issueAccessToken(address, accessJti); - const { rawToken: refreshToken } = await issueRefreshToken(address); - - // Set secure cookies - res.cookie(ACCESS_TOKEN_COOKIE, accessToken, { - ...COOKIE_BASE_OPTIONS, - maxAge: ACCESS_TOKEN_TTL_SEC * 1000, - }); - res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, { - ...COOKIE_BASE_OPTIONS, - maxAge: REFRESH_TOKEN_TTL_SEC * 1000, - }); - - return res.status(200).json({ - access_token: accessToken, - refresh_token: refreshToken, - token_type: "Bearer", - expires_in: ACCESS_TOKEN_TTL_SEC, - }); - } catch (error) { - console.error("[auth/verify] Unexpected error:", error); - return res.status(500).json({ error: "Internal server error" }); - } - } + "/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, + }, + }); + + if (!challengeRecord) { + return res.status(404).json({ + error: "No challenge found", + }); + } + + if ( + challengeRecord.expires_at.getTime() <= + Date.now() + ) { + await prisma.auth_challenges + .delete({ + where: { + address, + }, + }) + .catch(() => {}); + + return res.status(401).json({ + error: "Challenge expired", + }); + } + + let isValid = false; + + try { + const keypair = + Keypair.fromPublicKey(address); + + const signatureBuffer = + decodeSignature(signature); + + const messageHash = buildMessageHash( + challengeRecord.challenge + ); + + isValid = keypair.verify( + messageHash, + signatureBuffer + ); + } catch (err) { + console.warn( + "[auth/verify] Signature verification failed:", + err + ); + + isValid = false; + } + + if ( + !isValid && + process.env.NODE_ENV !== "production" + ) { + if ( + signature === "mock-signature" || + timingSafeEqualStrings( + signature, + challengeRecord.challenge + ) + ) { + isValid = true; + } + } + + if (!isValid) { + return res.status(401).json({ + error: "Invalid signature", + }); + } + + await prisma.auth_challenges.delete({ + where: { + address, + }, + }); + + const accessJti = crypto.randomUUID(); + + const accessToken = issueAccessToken( + address, + accessJti + ); + + const { rawToken: refreshToken } = + await issueRefreshToken(address); + + res.cookie( + ACCESS_TOKEN_COOKIE, + accessToken, + { + ...COOKIE_BASE_OPTIONS, + maxAge: + ACCESS_TOKEN_TTL_SEC * 1000, + } + ); + + res.cookie( + REFRESH_TOKEN_COOKIE, + refreshToken, + { + ...COOKIE_BASE_OPTIONS, + maxAge: + REFRESH_TOKEN_TTL_SEC * 1000, + } + ); + + return res.status(200).json({ + access_token: accessToken, + refresh_token: refreshToken, + 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", + }); + } + } ); // --------------------------------------------------------------------------- -// Route: POST /api/v1/auth/refresh -// -// BE-W3A-105 — Refresh token rotation endpoint. -// 1. Validates the incoming refresh token against the hashed DB record. -// 2. Checks expiry and revocation status. -// 3. Issues a new access token + new refresh token. -// 4. Marks the old refresh token as revoked (rotation — prevents reuse). +// Route: POST /refresh // --------------------------------------------------------------------------- interface RefreshBody { - refresh_token?: string; + refresh_token?: string; } router.post( - "/refresh", - async (req: Request<{}, {}, RefreshBody>, res: Response) => { - try { - let refreshToken = req.body.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" }); - } - - // Hash the incoming token and look it up — never store/compare raw. - const incomingHash = crypto - .createHash("sha256") - .update(refreshToken) - .digest("hex"); - - const record = await prisma.refresh_tokens.findUnique({ - where: { token_hash: incomingHash }, - }); - - if (!record) { - return res.status(401).json({ error: "Invalid refresh token" }); - } - - if (record.revoked) { - // A revoked token being replayed may indicate token theft. - // Log the event for incident response and reject hard. - console.warn( - `[auth/refresh] Revoked token replay attempt for address ${record.address}` - ); - 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" }); - } - - // ── Rotate: issue new tokens, revoke old refresh token ──────────────── - const newAccessJti = crypto.randomUUID(); - const newAccessToken = issueAccessToken(record.address, newAccessJti); - const { rawToken: newRefreshToken } = await issueRefreshToken( - record.address, - record.id // Marks this record as revoked inside issueRefreshToken - ); - - // Set secure cookies - 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, - }); - - return res.status(200).json({ - access_token: newAccessToken, - refresh_token: newRefreshToken, - token_type: "Bearer", - expires_in: ACCESS_TOKEN_TTL_SEC, - }); - } catch (error) { - console.error("[auth/refresh] Unexpected error:", error); - return res.status(500).json({ error: "Internal server error" }); - } - } + "/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", + }); + } + + const incomingHash = crypto + .createHash("sha256") + .update(refreshToken) + .digest("hex"); + + const record = + await prisma.refresh_tokens.findUnique({ + where: { + token_hash: incomingHash, + }, + }); + + if (!record) { + return res.status(401).json({ + error: "Invalid refresh token", + }); + } + + if (record.revoked) { + console.warn( + `[auth/refresh] Revoked token replay attempt for ${record.address}` + ); + + 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 { + rawToken: newRefreshToken, + } = await issueRefreshToken( + record.address, + record.id + ); + + 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, + } + ); + + return res.status(200).json({ + access_token: newAccessToken, + refresh_token: newRefreshToken, + 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", + }); + } + } ); // --------------------------------------------------------------------------- -// Route: POST /api/v1/auth/logout -// -// BE-W3A-105 — Revokes both the access token (via Redis blacklist) and the -// refresh token (via DB revocation flag). +// Route: POST /logout // --------------------------------------------------------------------------- -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; - } - - // ── 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); - - 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" }); - } - }); +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); + } + + let refreshToken = + req.cookies?.[ + REFRESH_TOKEN_COOKIE + ]; + + const body = + req.body as RefreshBody; + + if ( + !refreshToken && + body.refresh_token + ) { + refreshToken = + body.refresh_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 { + // Ignore invalid/expired 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(() => {}); + } + + 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]", error); + + return res.status(500).json({ + error: "Internal server error", + }); + } + } +); // --------------------------------------------------------------------------- -// Utility exports — consumed by auth middleware in other routes +// Utility Exports // --------------------------------------------------------------------------- + export { isTokenBlacklisted, blacklistToken }; + export default router; \ No newline at end of file diff --git a/backend/tests/auth.test.ts b/backend/tests/auth.test.ts new file mode 100644 index 00000000..927ebcf3 --- /dev/null +++ b/backend/tests/auth.test.ts @@ -0,0 +1,133 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import Module from "node:module"; +import crypto from "node:crypto"; +import { Keypair } from "@stellar/stellar-sdk"; + +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 auth = require("../src/routes/auth") as typeof import("../src/routes/auth"); + +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); + assert.equal(auth.sanitizeStellarAddress(`${address.slice(0, -1)}A`), null); +}); + +test("verifies SEP-53/Freighter style signatures over the prefixed challenge digest", () => { + const keypair = Keypair.random(); + const address = keypair.publicKey(); + const challenge = auth.buildChallenge(address, "00000000-0000-4000-8000-000000000000"); + const digest = crypto + .createHash("sha256") + .update(Buffer.from("Stellar Signed Message:\n" + challenge, "utf8")) + .digest(); + const signature = keypair.sign(digest).toString("base64"); + + assert.equal(auth.verifyStellarSignature(address, challenge, signature), true); + assert.equal(auth.verifyStellarSignature(address, `${challenge}!`, signature), false); + assert.equal(auth.verifyStellarSignature(Keypair.random().publicKey(), challenge, signature), false); +}); + +test("enforces challenge timelines and rejects expired challenges", () => { + const now = new Date("2026-05-29T00:00:00.000Z"); + + assert.equal(auth.isChallengeFresh({ expires_at: new Date("2026-05-29T00:00:01.000Z") }, now), true); + assert.equal(auth.isChallengeFresh({ expires_at: new Date("2026-05-29T00:00:00.000Z") }, now), false); + assert.equal(auth.isChallengeFresh({ expires_at: new Date("2026-05-28T23:59:59.999Z") }, now), false); +}); + +test("performs Redis blacklist lookups with a 1ms timeout budget", async () => { + const revokedRedis = { get: async () => "1", set: async () => "OK", del: async () => 1 }; + assert.equal(await auth.isSessionRevoked(revokedRedis as any, "token-a"), true); + + const slowRedis = { + get: () => new Promise((resolve) => setTimeout(() => resolve("1"), 25)), + set: async () => "OK", + del: async () => 1, + }; + const startedAt = performance.now(); + assert.equal(await auth.isSessionRevoked(slowRedis as any, "token-b"), false); + assert.ok(performance.now() - startedAt < 20); +}); + +test("auth router returns 401 for bad signatures and consumes valid challenges once", async () => { + const express = require("express") as typeof import("express"); + const keypair = Keypair.random(); + const address = keypair.publicKey(); + const challenge = auth.buildChallenge(address, "11111111-1111-4111-8111-111111111111"); + const record = { address, challenge, expires_at: new Date(Date.now() + 60_000) }; + const sessions = new Map(); + let storedRecord: typeof record | null = record; + + const app = express(); + app.use(express.json()); + app.use("/auth", auth.createAuthRouter({ + prismaClient: { + auth_challenges: { + upsert: async () => record, + findUnique: async () => storedRecord, + deleteMany: async ({ where }) => { + if (storedRecord && storedRecord.address === where.address && storedRecord.challenge === where.challenge && storedRecord.expires_at > where.expires_at.gt) { + storedRecord = null; + return { count: 1 }; + } + return { count: 0 }; + }, + }, + sessions: { + create: async ({ data }) => { sessions.set(data.token, data); return data; }, + findUnique: async ({ where }) => sessions.get(where.token) ?? null, + deleteMany: async ({ where }) => ({ count: sessions.delete(where.token) ? 1 : 0 }), + }, + }, + redisClient: null, + })); + + const server = app.listen(0); + const addressInfo = server.address(); + assert.equal(typeof addressInfo, "object"); + const baseUrl = `http://127.0.0.1:${(addressInfo as { port: number }).port}/auth`; + + try { + const badResponse = await fetch(`${baseUrl}/verify`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ address, signature: Buffer.alloc(64).toString("base64") }), + }); + assert.equal(badResponse.status, 401); + + const digest = crypto + .createHash("sha256") + .update(Buffer.from("Stellar Signed Message:\n" + challenge, "utf8")) + .digest(); + const signature = keypair.sign(digest).toString("base64"); + const okResponse = await fetch(`${baseUrl}/verify`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ address, signature }), + }); + assert.equal(okResponse.status, 200); + assert.equal(sessions.size, 1); + + const replayResponse = await fetch(`${baseUrl}/verify`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ address, signature }), + }); + assert.equal(replayResponse.status, 401); + assert.equal(sessions.size, 1); + } finally { + await new Promise((resolve, reject) => server.close((error?: Error) => error ? reject(error) : resolve())); + } +});