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
72 changes: 72 additions & 0 deletions package-lock.json

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

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"express-rate-limit": "^8.6.2",
"helmet": "^8.3.0",
"hpp": "^0.2.3",
"ioredis": "^6.0.0",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"pg": "^8.23.0",
Expand All @@ -47,6 +48,7 @@
},
"devDependencies": {
"@types/express-rate-limit": "^5.1.3",
"@types/ioredis": "^4.28.10",
"@types/jest": "^29.5.12",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",
Expand All @@ -56,4 +58,4 @@
"typedoc": "^0.28.20",
"typescript": "^5.5.2"
}
}
}
64 changes: 64 additions & 0 deletions src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Request, Response, NextFunction } from 'express';
import Redis from 'ioredis';
import { AppError } from './error.middleware.js';

// Setup Redis Client
const redisClient = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', {
lazyConnect: true,
maxRetriesPerRequest: 0,
});

redisClient.on('error', (err) => {
console.error('Redis connection error:', err);
});

/**
* Redis-backed sliding window rate limiter
*
* @param windowMs Window duration in milliseconds
* @param maxRequests Maximum allowed requests per window
*/
export function slidingWindowRateLimiter(windowMs: number, maxRequests: number) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const ip = req.ip || req.socket.remoteAddress || 'unknown';
const key = `ratelimit:${req.originalUrl}:${ip}`;
const now = Date.now();
const windowStart = now - windowMs;

const multi = redisClient.multi();
// Remove old requests outside the window
multi.zremrangebyscore(key, 0, windowStart);
// Add current request timestamp
multi.zadd(key, now, `${now}-${Math.random()}`);
// Count requests in the window
multi.zcard(key);
// Expire the key after the window duration to save memory
multi.pexpire(key, windowMs);

const results = await multi.exec();

if (!results) {
throw new Error('Redis transaction failed');
}

const requestCount = results[2][1] as number;

res.setHeader('X-RateLimit-Limit', maxRequests);
res.setHeader('X-RateLimit-Remaining', Math.max(0, maxRequests - requestCount));

if (requestCount > maxRequests) {
throw new AppError(429, 'TOO_MANY_REQUESTS', 'Rate limit exceeded. Please try again later.');
}

next();
} catch (err) {
if (err instanceof AppError) {
return next(err);
}
console.error('Rate limiter error, failing open', err);
// Fail open if Redis is down
next();
}
};
}
6 changes: 5 additions & 1 deletion src/routes/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,18 @@ const tokenSchema = z.object({
})
});

import { slidingWindowRateLimiter } from '../middleware/rateLimit.js';

export function createAuthRouter(authService: SEP10AuthService): Router {
const router = Router();

const authLimiter = slidingWindowRateLimiter(60000, 100); // 100 requests per minute

/**
* GET /api/v1/auth/challenge
* Initiates SEP-10 Web Authentication flow by generating a challenge transaction.
*/
router.get('/challenge', validate(challengeSchema), (req: Request, res: Response, next: NextFunction) => {
router.get('/challenge', authLimiter, validate(challengeSchema), (req: Request, res: Response, next: NextFunction) => {
try {
const account = req.query.account as string;
const homeDomain = req.query.home_domain as string | undefined;
Expand Down
12 changes: 2 additions & 10 deletions src/services/sep10.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Keypair, Networks, WebAuth } from '@stellar/stellar-sdk';
import jwt from 'jsonwebtoken';
import { AuthUtil } from '../utils/AuthUtil.js';
import { AppError } from '../middleware/error.middleware.js';
import { SEP10ChallengeResponse, SEP10TokenResponse } from '../types/sep.js';

export class SEP10AuthService {
private readonly serverKeypair: Keypair;
private readonly networkPassphrase: string;
private readonly jwtSecret: string;

Check failure on line 9 in src/services/sep10.service.ts

View workflow job for this annotation

GitHub Actions / TypeScript Compiler

'jwtSecret' is declared but its value is never read.
private readonly anchorDomain: string;

constructor(
Expand Down Expand Up @@ -78,15 +78,7 @@
}

const clientAccount = signers[0];
const token = jwt.sign(
{
iss: `https://${this.anchorDomain}/auth`,
sub: clientAccount,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 86400 // 24 hours
},
this.jwtSecret
);
const token = AuthUtil.generateSep10Token(clientAccount, this.anchorDomain);

return { token };
} catch (err: unknown) {
Expand Down
49 changes: 49 additions & 0 deletions src/utils/AuthUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import jwt from 'jsonwebtoken';
import crypto from 'crypto';

// Generate an ephemeral RSA keypair for testing/dev if env vars are missing
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});

const PRIVATE_KEY = process.env.JWT_PRIVATE_KEY || privateKey;
export const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY || publicKey;

export interface Sep10JwtPayload {
iss: string; // The URL of the authorization server
sub: string; // The Stellar account ID of the client
iat: number; // Issued at
exp: number; // Expiration
client_domain?: string;
}

export class AuthUtil {
/**
* Generates a SEP-10 compliant JWT using the RS256 algorithm.
*/
static generateSep10Token(accountId: string, domain: string, clientDomain?: string): string {
const payload: Partial<Sep10JwtPayload> = {
iss: `https://${domain}/auth`,
sub: accountId,
};

if (clientDomain) {
payload.client_domain = clientDomain;
}

// Use RS256 algorithm as required by the overhaul
return jwt.sign(payload, PRIVATE_KEY, {
algorithm: 'RS256',
expiresIn: '24h' // 24 hours as specified in original sep10.service.ts
});
}

/**
* Verifies an RS256 JWT token.
*/
static verifySep10Token(token: string): Sep10JwtPayload {
return jwt.verify(token, PUBLIC_KEY, { algorithms: ['RS256'] }) as Sep10JwtPayload;
}
}
29 changes: 0 additions & 29 deletions src/utils/jwt.ts

This file was deleted.

43 changes: 43 additions & 0 deletions tests/rateLimit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import request from 'supertest';
import app from '../src/app';

// Note: ioredis needs to be mocked or a local redis instance must be running.
// If redis is down, it fails open, so for a unit test, we should mock the redis client or the middleware.
// For true integration test, we can mock the ioredis package.

jest.mock('ioredis', () => {
const RedisMock = jest.fn().mockImplementation(() => {
let requests = 0;
return {
on: jest.fn(),
multi: jest.fn().mockReturnValue({
zremrangebyscore: jest.fn().mockReturnThis(),
zadd: jest.fn().mockReturnThis(),
zcard: jest.fn().mockReturnThis(),
pexpire: jest.fn().mockReturnThis(),
exec: jest.fn().mockImplementation(async () => {
requests++;
return [[null, 1], [null, 1], [null, requests], [null, 1]];
})
})
};
});
return RedisMock;
});

describe('SEP-10 Rate Limiting Integration', () => {
it('should return 429 Too Many Requests after 100 requests', async () => {
// Send 100 successful requests
for (let i = 0; i < 100; i++) {
const res = await request(app)
.get('/api/v1/auth/challenge?account=GCXKG6RN4ONIEPCMNFB732A436Z5IGJUQYA8QW5FN6HP3SG6HQBQVAAK');
expect(res.status).toBe(200);
}

// 101st request should be rate limited
const resLimited = await request(app)
.get('/api/v1/auth/challenge?account=GCXKG6RN4ONIEPCMNFB732A436Z5IGJUQYA8QW5FN6HP3SG6HQBQVAAK');
expect(resLimited.status).toBe(429);
expect(resLimited.body.message).toBe('Rate limit exceeded. Please try again later.');
});
});
Loading