From 50eda04fd52df651091bd706f14eb74614919009 Mon Sep 17 00:00:00 2001 From: Darkvader-ship-it Date: Mon, 27 Jul 2026 10:56:06 +0100 Subject: [PATCH] feat: add per-wallet rate limiting and JWT sub wallet address Set JWT sub claim to wallet address instead of internal user ID. Update getCurrentUser to lookup by stellar address. Add per-wallet rate limiting middleware. Apply rate limits: 10 req/60s for investment, 5 req/60s for invoice publish. 429 responses include Retry-After header. Add tests for JWT subject/expiry/tampered/expired tokens. Add rate limit tests: wallet isolation, Retry-After, window reset. --- .../rate-limit-wallet.middleware.ts | 85 +++++ src/routes/investment.routes.ts | 9 +- src/routes/invoice.routes.ts | 8 + src/services/auth.service.ts | 8 +- tests/auth-jwt-subject-expiry.test.ts | 315 ++++++++++++++++++ tests/rate-limit-wallet.test.ts | 234 +++++++++++++ 6 files changed, 654 insertions(+), 5 deletions(-) create mode 100644 src/middleware/rate-limit-wallet.middleware.ts create mode 100644 tests/auth-jwt-subject-expiry.test.ts create mode 100644 tests/rate-limit-wallet.test.ts diff --git a/src/middleware/rate-limit-wallet.middleware.ts b/src/middleware/rate-limit-wallet.middleware.ts new file mode 100644 index 0000000..a9f2765 --- /dev/null +++ b/src/middleware/rate-limit-wallet.middleware.ts @@ -0,0 +1,85 @@ +import type { NextFunction, Request, Response } from "express"; +import type { AuthenticatedRequest } from "../types/auth"; +import { HttpError } from "../utils/http-error"; + +interface WalletRateLimitEntry { + count: number; + windowStart: number; +} + +interface WalletRateLimitConfig { + windowMs: number; + maxRequests: number; +} + +const stores = new Map>(); + +function getStore(name: string): Map { + let store = stores.get(name); + if (!store) { + store = new Map(); + stores.set(name, store); + } + return store; +} + +function getWalletAddress(req: Request): string | null { + const authReq = req as AuthenticatedRequest; + return authReq.user?.stellarAddress ?? null; +} + +export function createWalletRateLimiter(config: WalletRateLimitConfig, name: string) { + const store = getStore(name); + + // Periodically clean up stale entries + setInterval(() => { + const now = Date.now(); + for (const [key, entry] of store) { + if (now - entry.windowStart >= config.windowMs) { + store.delete(key); + } + } + }, config.windowMs).unref(); + + return (req: Request, res: Response, next: NextFunction): void => { + const wallet = getWalletAddress(req); + + if (!wallet) { + next(new HttpError(401, "Authentication required for rate-limited endpoint.")); + return; + } + + const now = Date.now(); + const entry = store.get(wallet); + + if (!entry || now - entry.windowStart >= config.windowMs) { + // Start a new window + store.set(wallet, { count: 1, windowStart: now }); + next(); + return; + } + + if (entry.count >= config.maxRequests) { + const retryAfterMs = config.windowMs - (now - entry.windowStart); + const retryAfterSeconds = Math.ceil(retryAfterMs / 1000); + + res.setHeader("Retry-After", String(retryAfterSeconds)); + res.status(429).json({ + success: false, + error: { + code: "RATE_LIMIT_EXCEEDED", + message: `Too many requests. Please wait ${retryAfterSeconds} seconds before retrying.`, + }, + }); + return; + } + + entry.count++; + next(); + }; +} + +// For testing: allow resetting stores +export function resetRateLimitStores(): void { + stores.clear(); +} \ No newline at end of file diff --git a/src/routes/investment.routes.ts b/src/routes/investment.routes.ts index 54035c8..2edacdc 100644 --- a/src/routes/investment.routes.ts +++ b/src/routes/investment.routes.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { InvestmentController } from "../controllers/investment.controller"; import { InvestmentService } from "../services/investment.service"; import { createAuthMiddleware } from "../middleware/auth.middleware"; +import { createWalletRateLimiter } from "../middleware/rate-limit-wallet.middleware"; import type { AuthService } from "../services/auth.service"; export interface InvestmentRouterDependencies { @@ -9,6 +10,12 @@ export interface InvestmentRouterDependencies { authService: AuthService; } +// Per-wallet rate limit: max 10 investment submissions per 60 seconds +const investmentRateLimiter = createWalletRateLimiter( + { windowMs: 60_000, maxRequests: 10 }, + "investment-create", +); + export function createInvestmentRouter({ investmentService, authService, @@ -18,7 +25,7 @@ export function createInvestmentRouter({ const authMiddleware = createAuthMiddleware(authService); // POST /api/v1/investments - Create a new investment commitment - router.post("/", authMiddleware, controller.createInvestment); + router.post("/", authMiddleware, investmentRateLimiter, controller.createInvestment); // GET /api/v1/investments/dashboard - Investor portfolio aggregate router.get("/dashboard", authMiddleware, controller.getDashboard); diff --git a/src/routes/invoice.routes.ts b/src/routes/invoice.routes.ts index 319bf21..4c2e407 100644 --- a/src/routes/invoice.routes.ts +++ b/src/routes/invoice.routes.ts @@ -6,6 +6,7 @@ import type { InvoiceService } from "../services/invoice.service"; import type { AppConfig } from "../config/env"; import { createInvoiceController } from "../controllers/invoice.controller"; import { authenticateJWT, requireKYC } from "../middleware/auth.middleware"; +import { createWalletRateLimiter } from "../middleware/rate-limit-wallet.middleware"; import { HttpError } from "../utils/http-error"; export interface InvoiceRouterDependencies { @@ -154,6 +155,12 @@ export function createInvoiceRouter({ const kycGating = requireKYC(config.kyc.skipVerification); + // Per-wallet rate limit: max 5 invoice publishes per 60 seconds + const publishRateLimiter = createWalletRateLimiter( + { windowMs: 60_000, maxRequests: 5 }, + "invoice-publish", + ); + // ============ INVOICE CRUD ENDPOINTS ============ // GET /api/v1/invoices - List invoices for authenticated seller @@ -193,6 +200,7 @@ export function createInvoiceRouter({ "/:id/publish", authenticateJWT, kycGating, + publishRateLimiter, controller.publishInvoice, ); diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 143c23a..1bfabf0 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -186,7 +186,7 @@ export class AuthService { throw new HttpError(401, "Invalid token payload."); } - const user = await this.userRepository.findById(payload.sub); + const user = await this.userRepository.findByStellarAddress(payload.sub); if (!user) { throw new HttpError(401, "User no longer exists."); @@ -225,14 +225,14 @@ export class AuthService { this.config.jwt.secret, { ...signOptions, - subject: user.id, + subject: user.stellarAddress, }, ); } } class TypeOrmUserRepository implements UserRepositoryContract { - constructor(private readonly repository: Repository) {} + constructor(private readonly repository: Repository) { } findById(id: string): Promise { return this.repository.findOne({ @@ -253,7 +253,7 @@ class TypeOrmUserRepository implements UserRepositoryContract { } class TypeOrmChallengeRepository implements ChallengeRepositoryContract { - constructor(private readonly repository: Repository) {} + constructor(private readonly repository: Repository) { } async create(input: CreateChallengeRecordInput): Promise { const entity = this.repository.create({ diff --git a/tests/auth-jwt-subject-expiry.test.ts b/tests/auth-jwt-subject-expiry.test.ts new file mode 100644 index 0000000..ac6a45f --- /dev/null +++ b/tests/auth-jwt-subject-expiry.test.ts @@ -0,0 +1,315 @@ +import crypto from "crypto"; +import jwt from "jsonwebtoken"; +import { Keypair, Networks } from "stellar-sdk"; +import { AuthService } from "../src/services/auth.service"; +import type { + ChallengeRepositoryContract, + UserRepositoryContract, +} from "../src/services/auth.service"; +import { KYCStatus, UserType } from "../src/types/enums"; +import { HttpError } from "../src/utils/http-error"; + +// Import app for full integration test +import request from "supertest"; +import { createApp } from "../src/app"; + +// ── In-memory repositories (same as auth.routes.test.ts) ── + +import { User } from "../src/models/User.model"; + +type InMemoryUser = User; + +interface InMemoryChallenge { + id: string; + stellarAddress: string; + nonceHash: string; + message: string; + network: string; + issuedAt: Date; + expiresAt: Date; + consumedAt: Date | null; +} + +class InMemoryUserRepository implements UserRepositoryContract { + private readonly users = new Map(); + + async findById(id: string) { + return this.users.get(id) ?? null; + } + + async findByStellarAddress(stellarAddress: string) { + return ( + [...this.users.values()].find( + (user) => user.stellarAddress === stellarAddress, + ) ?? null + ); + } + + async save(user: Partial) { + const now = new Date(); + const entity: InMemoryUser = { + id: crypto.randomUUID(), + stellarAddress: user.stellarAddress ?? "", + email: user.email ?? null, + userType: user.userType ?? UserType.INVESTOR, + kycStatus: user.kycStatus ?? KYCStatus.PENDING, + createdAt: user.createdAt ?? now, + updatedAt: user.updatedAt ?? now, + deletedAt: user.deletedAt ?? null, + invoices: user.invoices ?? [], + investments: user.investments ?? [], + transactions: user.transactions ?? [], + kycVerifications: user.kycVerifications ?? [], + notifications: user.notifications ?? [], + }; + + this.users.set(entity.id, entity); + return entity; + } +} + +class InMemoryChallengeRepository implements ChallengeRepositoryContract { + readonly challenges = new Map(); + + async create(input: InMemoryChallenge) { + const challenge: InMemoryChallenge = { + id: crypto.randomUUID(), + stellarAddress: input.stellarAddress, + nonceHash: input.nonceHash, + message: input.message, + network: input.network, + issuedAt: input.issuedAt, + expiresAt: input.expiresAt, + consumedAt: null, + }; + + this.challenges.set(challenge.id, challenge); + return challenge; + } + + async findByAddressAndNonceHash(stellarAddress: string, nonceHash: string) { + return ( + [...this.challenges.values()].find( + (challenge) => + challenge.stellarAddress === stellarAddress && + challenge.nonceHash === nonceHash, + ) ?? null + ); + } + + async consume(id: string, consumedAt: Date) { + const challenge = this.challenges.get(id); + + if (!challenge || challenge.consumedAt) { + return false; + } + + challenge.consumedAt = consumedAt; + return true; + } +} + +// ── Helpers ── + +const TEST_SECRET = "test-secret-for-jwt-tests"; + +function createAuthServiceWithTtl( + ttlString: string, + challengeTtlMs = 60_000, +): AuthService { + return new AuthService({ + userRepository: new InMemoryUserRepository(), + challengeRepository: new InMemoryChallengeRepository(), + config: { + jwt: { + secret: TEST_SECRET, + expiresIn: ttlString, + }, + auth: { + challengeTtlMs, + }, + stellar: { + network: "testnet", + networkPassphrase: Networks.TESTNET, + }, + }, + }); +} + +function createTestApp(ttlString = "15m") { + const userRepository = new InMemoryUserRepository(); + const challengeRepository = new InMemoryChallengeRepository(); + const authService = new AuthService({ + userRepository, + challengeRepository, + config: { + jwt: { + secret: TEST_SECRET, + expiresIn: ttlString, + }, + auth: { + challengeTtlMs: 60_000, + }, + stellar: { + network: "testnet", + networkPassphrase: Networks.TESTNET, + }, + }, + }); + + return { + app: createApp({ authService }), + challengeRepository, + authService, + }; +} + +// ── Unit Tests (AuthService internals) ── + +describe("JWT subject claim", () => { + it("should have sub equal to the wallet address used in the challenge", async () => { + const { app } = createTestApp(); + const keypair = Keypair.random(); + const walletAddress = keypair.publicKey(); + + // Complete full auth flow + const challengeRes = await request(app) + .post("/api/v1/auth/challenge") + .send({ publicKey: walletAddress }) + .expect(201); + + const { nonce, message } = challengeRes.body.challenge; + const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); + + const verifyRes = await request(app) + .post("/api/v1/auth/verify") + .send({ + publicKey: walletAddress, + nonce, + signature, + }) + .expect(200); + + const token = verifyRes.body.token; + + // Decode the token without verification to inspect the payload + const decoded = jwt.decode(token) as jwt.JwtPayload; + expect(decoded).not.toBeNull(); + expect(decoded!.sub).toBe(walletAddress); + }); +}); + +describe("JWT expiry", () => { + it("should be within 1 second of now + configured TTL", async () => { + const { app } = createTestApp("1h"); + const keypair = Keypair.random(); + const walletAddress = keypair.publicKey(); + + const challengeRes = await request(app) + .post("/api/v1/auth/challenge") + .send({ publicKey: walletAddress }) + .expect(201); + + const { nonce, message } = challengeRes.body.challenge; + const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); + + const verifyRes = await request(app) + .post("/api/v1/auth/verify") + .send({ + publicKey: walletAddress, + nonce, + signature, + }) + .expect(200); + + const token = verifyRes.body.token; + + // Decode to inspect exp claim + const decoded = jwt.decode(token) as jwt.JwtPayload; + expect(decoded).not.toBeNull(); + expect(decoded!.exp).toBeDefined(); + + const now = Math.floor(Date.now() / 1000); + const expectedExp = now + 3600; // 1h = 3600s + + // Allow 1 second tolerance + expect(Math.abs(decoded!.exp! - expectedExp)).toBeLessThanOrEqual(1); + }); +}); + +describe("Tampered JWT", () => { + it("should be rejected with 401 by the verification middleware", async () => { + const { app } = createTestApp(); + const keypair = Keypair.random(); + const walletAddress = keypair.publicKey(); + + // Get a valid token first + const challengeRes = await request(app) + .post("/api/v1/auth/challenge") + .send({ publicKey: walletAddress }) + .expect(201); + + const { nonce, message } = challengeRes.body.challenge; + const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); + + const verifyRes = await request(app) + .post("/api/v1/auth/verify") + .send({ + publicKey: walletAddress, + nonce, + signature, + }) + .expect(200); + + const validToken = verifyRes.body.token; + + // Tamper with the payload by modifying the second part of the JWT + const parts = validToken.split("."); + const tamperedPayload = Buffer.from( + JSON.stringify({ sub: "tampered-wallet", stellarAddress: "tampered", iat: 0, exp: 9999999999 }), + ).toString("base64url"); + const tamperedToken = [parts[0], tamperedPayload, parts[2]].join("."); + + // Hit /me with the tampered token + await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${tamperedToken}`) + .expect(401); + }); +}); + +describe("Expired JWT", () => { + it("should be rejected with 401 when TTL is set to -1s in test config", async () => { + // Create a service with a -1s TTL (effectively already expired) + const { app } = createTestApp("-1s"); + const keypair = Keypair.random(); + const walletAddress = keypair.publicKey(); + + const challengeRes = await request(app) + .post("/api/v1/auth/challenge") + .send({ publicKey: walletAddress }) + .expect(201); + + const { nonce, message } = challengeRes.body.challenge; + const signature = keypair.sign(Buffer.from(message, "utf8")).toString("base64"); + + // The token should have exp in the past + const verifyRes = await request(app) + .post("/api/v1/auth/verify") + .send({ + publicKey: walletAddress, + nonce, + signature, + }) + .expect(200); + + const expiredToken = verifyRes.body.token; + + // The verify endpoint may still issue the token (it just signs it), + // so the token itself will be expired. Now try to use it. + await request(app) + .get("/api/v1/auth/me") + .set("Authorization", `Bearer ${expiredToken}`) + .expect(401); + }); +}); \ No newline at end of file diff --git a/tests/rate-limit-wallet.test.ts b/tests/rate-limit-wallet.test.ts new file mode 100644 index 0000000..9d7a911 --- /dev/null +++ b/tests/rate-limit-wallet.test.ts @@ -0,0 +1,234 @@ +import request from "supertest"; +import express from "express"; +import jwt from "jsonwebtoken"; +import { createWalletRateLimiter, resetRateLimitStores } from "../src/middleware/rate-limit-wallet.middleware"; +import { createErrorMiddleware } from "../src/middleware/error.middleware"; +import { logger } from "../src/observability/logger"; +import { KYCStatus, UserType } from "../src/types/enums"; + +describe("Wallet-based rate limiting", () => { + const TEST_SECRET = "test-secret-rate-limit"; + const WALLET_A = "GAWalletA123456789012345678901234567890123456789012345"; + const WALLET_B = "GBWalletB123456789012345678901234567890123456789012345"; + + function createToken(walletAddress: string): string { + return jwt.sign( + { sub: walletAddress, stellarAddress: walletAddress }, + TEST_SECRET, + ); + } + + let app: express.Application; + + beforeEach(() => { + resetRateLimitStores(); + + process.env.JWT_SECRET = TEST_SECRET; + + app = express(); + app.use(express.json()); + + // Create a test endpoint with wallet rate limiter (max 3 per 60s) + const testRateLimiter = createWalletRateLimiter( + { windowMs: 60_000, maxRequests: 3 }, + "test-endpoint", + ); + + app.post( + "/api/v1/test-rate-limit", + (req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, TEST_SECRET) as any; + (req as any).user = { + id: payload.sub, + stellarAddress: payload.stellarAddress, + email: null, + userType: UserType.INVESTOR, + kycStatus: KYCStatus.APPROVED, + createdAt: new Date(), + updatedAt: new Date(), + }; + next(); + } catch { + res.status(401).json({ error: "Invalid token" }); + } + }, + testRateLimiter, + (_req, res) => { + res.status(200).json({ success: true }); + }, + ); + + app.use(createErrorMiddleware(logger)); + }); + + afterEach(() => { + delete process.env.JWT_SECRET; + }); + + it("should allow requests up to the limit", async () => { + const tokenA = createToken(WALLET_A); + + // First 3 requests should succeed + for (let i = 0; i < 3; i++) { + await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(200); + } + }); + + it("should return 429 after exceeding the limit", async () => { + const tokenA = createToken(WALLET_A); + + // Exhaust the 3 request limit + for (let i = 0; i < 3; i++) { + await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(200); + } + + // 4th request should be rate limited + const response = await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(429); + + expect(response.body).toMatchObject({ + success: false, + error: { + code: "RATE_LIMIT_EXCEEDED", + }, + }); + }); + + it("should include Retry-After header in 429 response", async () => { + const tokenA = createToken(WALLET_A); + + // Exhaust the limit + for (let i = 0; i < 3; i++) { + await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(200); + } + + const response = await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(429); + + expect(response.headers).toHaveProperty("retry-after"); + const retryAfter = parseInt(response.headers["retry-after"], 10); + expect(retryAfter).toBeGreaterThanOrEqual(1); + expect(retryAfter).toBeLessThanOrEqual(60); + }); + + it("should not affect wallet B when wallet A is rate limited", async () => { + const tokenA = createToken(WALLET_A); + const tokenB = createToken(WALLET_B); + + // Exhaust wallet A's limit + for (let i = 0; i < 3; i++) { + await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(200); + } + + // Wallet A should be rate limited + await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenA}`) + .expect(429); + + // Wallet B should still be allowed + await request(app) + .post("/api/v1/test-rate-limit") + .set("Authorization", `Bearer ${tokenB}`) + .expect(200); + }); + + it("should allow requests again after the window resets", async () => { + // Use a very short window for testing + resetRateLimitStores(); + + const shortWindowApp = express(); + shortWindowApp.use(express.json()); + + const shortWindowLimiter = createWalletRateLimiter( + { windowMs: 100, maxRequests: 1 }, // 100ms window, 1 request max + "short-window-test", + ); + + shortWindowApp.post( + "/api/v1/short-window", + (req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + const token = authHeader.slice(7); + try { + const payload = jwt.verify(token, TEST_SECRET) as any; + (req as any).user = { + id: payload.sub, + stellarAddress: payload.stellarAddress, + email: null, + userType: UserType.INVESTOR, + kycStatus: KYCStatus.APPROVED, + createdAt: new Date(), + updatedAt: new Date(), + }; + next(); + } catch { + res.status(401).json({ error: "Invalid token" }); + } + }, + shortWindowLimiter, + (_req, res) => { + res.status(200).json({ success: true }); + }, + ); + + shortWindowApp.use(createErrorMiddleware(logger)); + + const tokenA = createToken(WALLET_A); + + // First request should succeed + await request(shortWindowApp) + .post("/api/v1/short-window") + .set("Authorization", `Bearer ${tokenA}`) + .expect(200); + + // Second request should be rate limited + await request(shortWindowApp) + .post("/api/v1/short-window") + .set("Authorization", `Bearer ${tokenA}`) + .expect(429); + + // Wait for window to reset + await new Promise((resolve) => setTimeout(resolve, 150)); + + // After window reset, requests should succeed again + await request(shortWindowApp) + .post("/api/v1/short-window") + .set("Authorization", `Bearer ${tokenA}`) + .expect(200); + }); + + it("should return 401 if no wallet address in request", async () => { + // Send request without auth + await request(app) + .post("/api/v1/test-rate-limit") + .expect(401); + }); +}); \ No newline at end of file