Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"ioredis": "^5.11.0",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.1",
Expand Down
84 changes: 82 additions & 2 deletions apps/backend/src/__tests__/auth.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ vi.mock('@stellar/stellar-sdk', () => ({
// ── Import app after mocks are registered ─────────────────────────────────

const { app } = await import('../app.js');
const { challengeLimiter, verifyLimiter } = await import('../routes/auth.js');

function resetRateLimiters() {
challengeLimiter.resetKey('127.0.0.1');
verifyLimiter.resetKey('127.0.0.1');
}

// ── Helpers ───────────────────────────────────────────────────────────────

Expand All @@ -51,7 +57,10 @@ function setupInsert(userId = 'new-user-id') {
// ── Tests ─────────────────────────────────────────────────────────────────

describe('POST /auth/challenge', () => {
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
resetRateLimiters();
});

it('returns 200 with message and nonce for valid walletAddress', async () => {
const res = await request(app).post('/auth/challenge').send({ walletAddress: WALLET });
Expand Down Expand Up @@ -83,7 +92,10 @@ describe('POST /auth/challenge', () => {
});

describe('POST /auth/verify', () => {
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
resetRateLimiters();
});

it('returns 200 with JWT token for valid new-user flow', async () => {
mockConsumeNonce.mockReturnValue(true);
Expand Down Expand Up @@ -165,3 +177,71 @@ describe('POST /auth/verify', () => {
expect(res.body).toHaveProperty('error');
});
});

describe('Auth rate limiting', () => {
beforeEach(() => {
vi.clearAllMocks();
resetRateLimiters();
mockConsumeNonce.mockReturnValue(true);
mockVerify.mockReturnValue(true);
mockFindFirst.mockResolvedValue({ userId: 'existing-user-id', address: WALLET });
});

it('allows up to 10 /auth/challenge requests per minute, blocks the 11th with 429', async () => {
for (let i = 0; i < 10; i++) {
const res = await request(app).post('/auth/challenge').send({ walletAddress: WALLET });
expect(res.status).toBe(200);
}

const blocked = await request(app).post('/auth/challenge').send({ walletAddress: WALLET });
expect(blocked.status).toBe(429);
expect(blocked.headers['retry-after']).toBeDefined();
});

it('allows up to 5 /auth/verify requests per minute, blocks the 6th with 429', async () => {
for (let i = 0; i < 5; i++) {
const res = await request(app)
.post('/auth/verify')
.send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE });
expect(res.status).toBe(200);
}

const blocked = await request(app)
.post('/auth/verify')
.send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE });
expect(blocked.status).toBe(429);
expect(blocked.headers['retry-after']).toBeDefined();
});

it('challenge and verify limiters are independent', async () => {
// Exhaust verify limit
for (let i = 0; i < 5; i++) {
await request(app)
.post('/auth/verify')
.send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE });
}
const verifyBlocked = await request(app)
.post('/auth/verify')
.send({ walletAddress: WALLET, signature: SIGNATURE, nonce: NONCE });
expect(verifyBlocked.status).toBe(429);

// Challenge limit should still allow requests
const challengeRes = await request(app).post('/auth/challenge').send({ walletAddress: WALLET });
expect(challengeRes.status).toBe(200);
});

it('does not affect authenticated routes (/me returns its normal status under heavy load)', async () => {
// Hammer /me well past the auth limits — it must not return 429
for (let i = 0; i < 20; i++) {
const res = await request(app).get('/me');
expect(res.status).not.toBe(429);
}
});

it('does not affect the /health endpoint under heavy load', async () => {
for (let i = 0; i < 20; i++) {
const res = await request(app).get('/health');
expect(res.status).not.toBe(429);
}
});
});
23 changes: 21 additions & 2 deletions apps/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto';
import { Router } from 'express';
import type { Request, Response, IRouter } from 'express';
import rateLimit, { type RateLimitRequestHandler } from 'express-rate-limit';
import { Keypair } from '@stellar/stellar-sdk';
import { db } from '../db/index.js';
import { users, wallets } from '../db/schema.js';
Expand All @@ -17,8 +18,26 @@ import {

export const authRouter: IRouter = Router();

const rateLimitedResponse = { error: 'Too many requests' };

export const challengeLimiter: RateLimitRequestHandler = rateLimit({
windowMs: 60 * 1000,
limit: 10,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: rateLimitedResponse,
});

export const verifyLimiter: RateLimitRequestHandler = rateLimit({
windowMs: 60 * 1000,
limit: 5,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: rateLimitedResponse,
});

// Step 1: client requests a challenge nonce for a wallet address
authRouter.post('/challenge', validate(ChallengeSchema), (req: Request, res: Response) => {
authRouter.post('/challenge', challengeLimiter, validate(ChallengeSchema), (req: Request, res: Response) => {
const { walletAddress } = req.body as ChallengeBody;

const nonce = createNonce(walletAddress);
Expand All @@ -28,7 +47,7 @@ authRouter.post('/challenge', validate(ChallengeSchema), (req: Request, res: Res
});

// Step 2: client signs the message and submits the signature
authRouter.post('/verify', validate(VerifySchema), async (req: Request, res: Response) => {
authRouter.post('/verify', verifyLimiter, validate(VerifySchema), async (req: Request, res: Response) => {
const { walletAddress, signature, nonce } = req.body as VerifyBody;

// Validate and consume nonce
Expand Down
20 changes: 20 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading