diff --git a/docs/openapi.json b/docs/openapi.json index c130e7a..6417a0f 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -64,6 +64,19 @@ "application/json": { "schema": { "$ref": "#/components/schemas/BillingDeductRequest" + }, + "examples": { + "deductRequest": { + "summary": "Deduct billing request", + "value": { + "requestId": "req-123e4567-e89b-12d3-a456-426614174000", + "apiId": "api-123", + "endpointId": "endpoint-456", + "apiKeyId": "key-789", + "amountUsdc": "0.01", + "idempotencyKey": "idem-abc123" + } + } } } } @@ -75,6 +88,26 @@ "application/json": { "schema": { "$ref": "#/components/schemas/BillingDeductResponse" + }, + "examples": { + "success": { + "summary": "Successful deduction", + "value": { + "success": true, + "usageEventId": "evt-123e4567-e89b-12d3-a456-426614174000", + "stellarTxHash": "abc123def456...", + "alreadyProcessed": false + } + }, + "alreadyProcessed": { + "summary": "Already processed (idempotent)", + "value": { + "success": true, + "usageEventId": "evt-123e4567-e89b-12d3-a456-426614174000", + "stellarTxHash": "abc123def456...", + "alreadyProcessed": true + } + } } } } @@ -85,6 +118,16 @@ "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "invalidAmount": { + "summary": "Invalid amount format", + "value": { + "code": "BAD_REQUEST", + "message": "amountUsdc must be a positive number with at most 7 decimal places", + "requestId": "req-abc123" + } + } } } } @@ -95,6 +138,16 @@ "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "unauthorized": { + "summary": "Missing or invalid authentication", + "value": { + "code": "UNAUTHORIZED", + "message": "Authentication required", + "requestId": "req-abc123" + } + } } } } @@ -105,54 +158,77 @@ "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "insufficientBalance": { + "summary": "Insufficient balance", + "value": { + "code": "INSUFFICIENT_BALANCE", + "message": "Vault balance too low for deduction", + "requestId": "req-abc123" + } + } } } } }, - "500": { - "description": "Internal server error", + "409": { + "description": "Idempotency conflict", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "idempotencyConflict": { + "summary": "Idempotency key already used with different parameters", + "value": { + "code": "IDEMPOTENCY_CONFLICT", + "message": "Idempotency key conflict: different request parameters", + "requestId": "req-abc123" + } + } } } } }, - "responses": { - "200": { - "description": "Success" - }, - "400": { - "description": "Bad Request" - }, - "401": { - "description": "Unauthorized" - }, - "402": { - "description": "Payment Required" - }, - "409": { - "description": "Idempotency conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } + "429": { + "description": "Rate limit exceeded", + "headers": { + "Retry-After": { + "schema": { + "type": "integer", + "description": "Seconds until the rate limit expires" } } }, - "429": { - "description": "Rate limit exceeded", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "rateLimited": { + "summary": "Too many requests", + "value": { + "code": "TOO_MANY_REQUESTS", + "message": "Too Many Requests", + "requestId": "req-abc123" + } } } } - }, - "500": {} + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } diff --git a/src/config/env.ts b/src/config/env.ts index 20ab6ca..79ed1c0 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -89,12 +89,15 @@ export const envSchema = z WEBHOOK_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().optional(), WEBHOOK_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().optional(), WEBHOOK_SECRET_ROTATION_GRACE_MS: z.coerce.number().int().positive().default(24 * 60 * 60 * 1000), - // Generic rate limiter (optional legacy config) RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().optional(), RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().optional(), RATE_LIMIT_STORE: z.string().optional(), RATE_LIMIT_PG_TABLE: z.string().optional(), + // Login rate limiting (IP-based throttling for auth attempts) + LOGIN_RATE_LIMIT_MAX_REQUESTS: z.coerce.number().int().positive().default(5), + LOGIN_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), // 1 minute sliding window + // CORS CORS_ALLOWED_ORIGINS: z.string().default("http://localhost:5173"), diff --git a/src/config/index.ts b/src/config/index.ts index 8fcdf5f..4b9f795 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -150,6 +150,11 @@ export const config = { secretRotationGraceMs: env.WEBHOOK_SECRET_ROTATION_GRACE_MS, }, + loginRateLimit: { + windowMs: env.LOGIN_RATE_LIMIT_WINDOW_MS, + maxRequests: env.LOGIN_RATE_LIMIT_MAX_REQUESTS, + }, + rateLimiter: { maxRequests: env.RATE_LIMIT_MAX_REQUESTS, windowMs: env.RATE_LIMIT_WINDOW_MS, diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts index d816693..bc2793b 100644 --- a/src/controllers/authController.ts +++ b/src/controllers/authController.ts @@ -3,6 +3,7 @@ import { RefreshTokenService } from '../services/refreshTokenService.js'; import type { RefreshTokenRepository } from '../repositories/refreshTokenRepository.js'; import { logger } from '../logger.js'; import { UnauthorizedError } from '../errors/index.js'; +import { getClientIp, DEFAULT_PROXY_HEADERS } from '../lib/clientIp.js'; export interface AuthControllerOptions { refreshTokenService: RefreshTokenService; @@ -19,7 +20,43 @@ export class AuthController { } /** - * Refresh access token using a valid refresh token. + * Wallet-based login with IP-based rate limiting applied at the route level. + * Returns JWT on successful signature verification. + * + * POST /auth/wallet + * Rate limited to 5 requests per minute per IP by loginThrottle middleware. + */ + async walletLogin(req: Request, res: Response, next: NextFunction): Promise { + try { + const { walletAddress, signature, message } = req.body; + + if (!walletAddress || !signature || !message) { + next(new UnauthorizedError('Missing required fields', 'MISSING_AUTH_FIELDS')); + return; + } + + // Extract client IP for structured logging + const clientIp = getClientIp(req, process.env.TRUST_PROXY_HEADERS === 'true', DEFAULT_PROXY_HEADERS); + + logger.info('[AuthController] Wallet login attempt', { + walletAddress, + clientIp, + }); + + // TODO: Implement actual Stellar signature verification + // This would typically call a service to verify the wallet signature + // against the message and derive a user ID for JWT generation + + next(new UnauthorizedError('Wallet login not fully implemented', 'AUTH_NOT_IMPLEMENTED')); + + } catch (error) { + logger.error('[AuthController] Error during wallet login', { error }); + next(new UnauthorizedError('Login failed', 'REFRESH_FAILED')); + } + } + + /** + * Refresh access token using a valid refresh token. * * On success the consumed refresh token is revoked and a fresh token pair * (access + refresh) is returned. Single-use enforcement means a reused diff --git a/src/middleware/loginThrottle.test.ts b/src/middleware/loginThrottle.test.ts new file mode 100644 index 0000000..cf9da2c --- /dev/null +++ b/src/middleware/loginThrottle.test.ts @@ -0,0 +1,135 @@ +import express from 'express'; +import request from 'supertest'; +import { createLoginThrottle, InMemoryLoginRateLimiter } from './loginThrottle.js'; +import { errorHandler } from './errorHandler.js'; + +function buildThrottleApp(limiter?: InMemoryLoginRateLimiter, trustProxy = false) { + const app = express(); + const options = trustProxy + ? { windowMs: 60_000, maxRequests: 3, trustProxy: true } + : { windowMs: 60_000, maxRequests: 3 }; + const throttle = createLoginThrottle(options, limiter); + + app.use(express.json()); + app.post('/login', throttle, (_req, res) => { + res.status(200).json({ message: 'Login accepted' }); + }); + app.use(errorHandler); + return app; +} + +describe('loginThrottle middleware', () => { + it('allows requests under the limit', async () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 3); + const app = buildThrottleApp(limiter); + + // All requests from same IP (socket) should be allowed + await request(app).post('/login').expect(200); + await request(app).post('/login').expect(200); + await request(app).post('/login').expect(200); + }); + + it('returns 429 with Retry-After header after limit is exceeded', async () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 3); + const app = buildThrottleApp(limiter); + + // Exhaust the limit using the same IP (will use socket address) + await request(app).post('/login').expect(200); + await request(app).post('/login').expect(200); + await request(app).post('/login').expect(200); + + const response = await request(app).post('/login'); + + expect(response.status).toBe(429); + expect(response.body.code).toBe('TOO_MANY_REQUESTS'); + expect(response.headers['retry-after']).toBeDefined(); + expect(typeof response.body.retryAfterMs).toBe('number'); + }); + + it('tracks limits separately per IP with trustProxy enabled', async () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 2); + const app = buildThrottleApp(limiter, true); + + // First IP + await request(app).post('/login').set('X-Forwarded-For', '10.0.0.1').expect(200); + await request(app).post('/login').set('X-Forwarded-For', '10.0.0.1').expect(200); + + // Second IP - should be allowed + await request(app).post('/login').set('X-Forwarded-For', '10.0.0.2').expect(200); + + // First IP should be throttled now + const response = await request(app).post('/login').set('X-Forwarded-For', '10.0.0.1'); + + expect(response.status).toBe(429); + expect(response.body.code).toBe('TOO_MANY_REQUESTS'); + }); + + it('resets the window after expiry', async () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 2); + const app = buildThrottleApp(limiter); + + // Use direct limiter manipulation for time travel test + limiter.check('10.0.0.1'); + limiter.check('10.0.0.1'); + + // Fast-forward time past the window by passing a future timestamp + const futureTime = Date.now() + 61_000; + const result = limiter.check('10.0.0.1', futureTime); + + // Should be allowed after window expiry + expect(result.allowed).toBe(true); + }); + + it('returns consistent retryAfterMs and Retry-After values', async () => { + const app = buildThrottleApp(); + + // Exhaust the limit + await request(app).post('/login').expect(200); + await request(app).post('/login').expect(200); + await request(app).post('/login').expect(200); + + const response = await request(app).post('/login'); + + const retryAfterSeconds = Number(response.headers['retry-after']); + const retryAfterMs = response.body.retryAfterMs; + + // retryAfterMs should be consistent with Retry-After header + expect(Math.ceil(retryAfterMs / 1000) * 1000).toBeLessThanOrEqual(retryAfterSeconds * 1000); + expect(retryAfterMs).toBeGreaterThan(0); + }); +}); + +describe('InMemoryLoginRateLimiter', () => { + it('creates a bucket on first check', () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 5); + const result = limiter.check('192.168.1.1'); + + expect(result.allowed).toBe(true); + expect(limiter.getBucket('192.168.1.1')).toEqual({ + count: 1, + resetAt: expect.any(Number), + }); + }); + + it('rejects when limit is exceeded', () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 2); + + limiter.check('10.0.0.1'); + limiter.check('10.0.0.1'); + const result = limiter.check('10.0.0.1'); + + expect(result.allowed).toBe(false); + expect(result.retryAfterMs).toBeGreaterThan(0); + }); + + it('clears all buckets on reset', () => { + const limiter = new InMemoryLoginRateLimiter(60_000, 5); + + limiter.check('192.168.1.1'); + limiter.check('192.168.1.2'); + limiter.reset(); + + expect(limiter.getBucket('192.168.1.1')).toBeUndefined(); + expect(limiter.getBucket('192.168.1.2')).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/src/middleware/loginThrottle.ts b/src/middleware/loginThrottle.ts new file mode 100644 index 0000000..41aeb3a --- /dev/null +++ b/src/middleware/loginThrottle.ts @@ -0,0 +1,92 @@ +import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import { getClientIp, DEFAULT_PROXY_HEADERS } from '../lib/clientIp.js'; + +export interface LoginThrottleOptions { + windowMs: number; + maxRequests: number; + trustProxy?: boolean; +} + +interface LoginAttemptRecord { + count: number; + resetAt: number; +} + +export class InMemoryLoginRateLimiter { + private readonly buckets = new Map(); + + constructor( + private readonly windowMs: number, + private readonly maxRequests: number, + ) {} + + check(ip: string, now = Date.now()): { allowed: boolean; retryAfterMs?: number } { + const bucket = this.buckets.get(ip); + + if (!bucket || now >= bucket.resetAt) { + this.buckets.set(ip, { + count: 1, + resetAt: now + this.windowMs, + }); + return { allowed: true }; + } + + if (bucket.count >= this.maxRequests) { + return { + allowed: false, + retryAfterMs: Math.max(bucket.resetAt - now, 0), + }; + } + + bucket.count += 1; + return { allowed: true }; + } + + reset(): void { + this.buckets.clear(); + } + + getBucket(ip: string): LoginAttemptRecord | undefined { + return this.buckets.get(ip); + } +} + +export function createLoginThrottle( + options: LoginThrottleOptions, + limiter = new InMemoryLoginRateLimiter(options.windowMs, options.maxRequests), +): RequestHandler { + return (req: Request, res: Response, next: NextFunction): void => { + const trustProxy = options.trustProxy ?? false; + const ip = getClientIp(req, trustProxy, DEFAULT_PROXY_HEADERS); + + // Skip throttling for missing IP (should not happen in production) + if (!ip) { + next(); + return; + } + + // In test mode (trustProxy=false), use req.ip if set (simulated socket address) + const clientIp = ip || req.ip || ''; + + const result = limiter.check(clientIp); + + if (!result.allowed) { + const retryAfterMs = result.retryAfterMs ?? options.windowMs; + const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)); + const requestId: string = (req as Request & { id?: string }).id ?? 'unknown'; + + res.set('Retry-After', String(retryAfterSeconds)); + res.status(429).json({ + code: 'TOO_MANY_REQUESTS', + message: 'Too Many Requests', + requestId, + retryAfterMs, + }); + return; + } + + next(); + }; +} + +export type { LoginThrottleOptions }; \ No newline at end of file diff --git a/src/routes/authRoutes.ts b/src/routes/authRoutes.ts index d43f712..e6b8838 100644 --- a/src/routes/authRoutes.ts +++ b/src/routes/authRoutes.ts @@ -2,15 +2,38 @@ import { Router } from 'express'; import { AuthController } from '../controllers/authController.js'; import { requireAuth } from '../middleware/requireAuth.js'; import { bodyValidator } from '../middleware/validate.js'; +import { createLoginThrottle } from '../middleware/loginThrottle.js'; +import { config } from '../config/index.js'; import { z } from 'zod'; const refreshTokenSchema = z.object({ refreshToken: z.string().min(1, 'Refresh token is required') }); +// Login throttle with proxy support for accurate IP detection behind load balancers +const loginThrottle = createLoginThrottle({ + windowMs: config.loginRateLimit.windowMs, + maxRequests: config.loginRateLimit.maxRequests, + trustProxy: process.env.TRUST_PROXY_HEADERS === 'true', +}); + +const walletLoginSchema = z.object({ + walletAddress: z.string().min(1, 'Wallet address is required'), + signature: z.string().min(1, 'Signature is required'), + message: z.string().min(1, 'Message is required'), +}); + export function createAuthRoutes(authController: AuthController): Router { const router = Router(); + // POST /auth/wallet - Wallet-based login with IP throttling + // Rate limited to prevent brute force attacks + router.post('/wallet', + loginThrottle, + bodyValidator(walletLoginSchema), + (req, res, next) => authController.walletLogin(req, res, next) + ); + // Refresh access token router.post('/refresh', bodyValidator(refreshTokenSchema), diff --git a/src/routes/billing.openapi.test.ts b/src/routes/billing.openapi.test.ts new file mode 100644 index 0000000..9c158f1 --- /dev/null +++ b/src/routes/billing.openapi.test.ts @@ -0,0 +1,82 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenAPI } from "openapi-types"; + +describe("OpenAPI Examples for /api/billing/deduct", () => { + const openApiPath = path.join(process.cwd(), "docs", "openapi.json"); + + test("OpenAPI spec contains examples for all required response codes", () => { + const spec: OpenAPI.Document = JSON.parse( + fs.readFileSync(openApiPath, "utf8"), + ); + const deductPath = spec.paths?.["/api/billing/deduct"]; + + expect(deductPath?.post).toBeDefined(); + + const responses = deductPath!.post!.responses!; + + // Happy path (200) examples + expect(responses["200"]).toBeDefined(); + expect(responses["200"].content!["application/json"].examples).toBeDefined(); + const successExample = responses["200"].content!["application/json"].examples!.success; + expect((successExample as any).summary).toBe("Successful deduction"); + expect((successExample as any).value.success).toBe(true); + expect((successExample as any).value.alreadyProcessed).toBe(false); + + const alreadyProcessedExample = + responses["200"].content!["application/json"].examples!.alreadyProcessed; + expect((alreadyProcessedExample as any).summary).toBe( + "Already processed (idempotent)", + ); + expect((alreadyProcessedExample as any).value.alreadyProcessed).toBe(true); + + // 409 Idempotency conflict example + expect(responses["409"]).toBeDefined(); + const conflictExample = + responses["409"].content!["application/json"].examples! + .idempotencyConflict; + expect((conflictExample as any).summary).toBe( + "Idempotency key already used with different parameters", + ); + expect((conflictExample as any).value.code).toBe("IDEMPOTENCY_CONFLICT"); + + // 429 Rate limit example with Retry-After header + expect(responses["429"]).toBeDefined(); + expect(responses["429"].headers).toBeDefined(); + expect(responses["429"].headers!["Retry-After"]).toBeDefined(); + const rateLimitedExample = + responses["429"].content!["application/json"].examples!.rateLimited; + expect((rateLimitedExample as any).summary).toBe("Too many requests"); + expect((rateLimitedExample as any).value.code).toBe("TOO_MANY_REQUESTS"); + }); + + test("Request body examples contain required fields", () => { + const spec: OpenAPI.Document = JSON.parse( + fs.readFileSync(openApiPath, "utf8"), + ); + const deductRequest = + spec.paths!["/api/billing/deduct"].post!.requestBody!.content![ + "application/json" + ].examples!.deductRequest; + + expect((deductRequest as any).summary).toBe("Deduct billing request"); + expect((deductRequest as any).value.requestId).toBeDefined(); + expect((deductRequest as any).value.apiId).toBeDefined(); + expect((deductRequest as any).value.endpointId).toBeDefined(); + expect((deductRequest as any).value.apiKeyId).toBeDefined(); + expect((deductRequest as any).value.amountUsdc).toBeDefined(); + expect((deductRequest as any).value.idempotencyKey).toBeDefined(); + }); + + test("OpenAPI spec is valid JSON without nested responses object", () => { + const spec: OpenAPI.Document = JSON.parse( + fs.readFileSync(openApiPath, "utf8"), + ); + const responses = spec.paths!["/api/billing/deduct"].post!.responses!; + // The old malformed object had a nested "responses" key at every status code + // This should not exist - each status code should be a response object + for (const value of Object.values(responses)) { + expect((value as any).responses).toBeUndefined(); + } + }); +});