From a2b5de0d9836601a791e973bd0dbaf893db038da Mon Sep 17 00:00:00 2001 From: blessme247 Date: Tue, 24 Mar 2026 07:30:48 +0100 Subject: [PATCH 1/4] feat: implement role based auth as a middleware --- package-lock.json | 431 +++++++++++++++++- package.json | 6 +- src/ROLE_BASED_AUTH.md | 213 +++++++++ src/index.ts | 2 +- src/lib/authorization.ts | 148 ++++++ src/lib/data.ts | 65 +++ src/lib/types.ts | 43 ++ .../__tests__/authorization.test.ts | 169 +++++++ src/middleware/authorization.ts | 250 ++++++++++ 9 files changed, 1319 insertions(+), 8 deletions(-) create mode 100644 src/ROLE_BASED_AUTH.md create mode 100644 src/lib/authorization.ts create mode 100644 src/lib/data.ts create mode 100644 src/lib/types.ts create mode 100644 src/middleware/__tests__/authorization.test.ts create mode 100644 src/middleware/authorization.ts diff --git a/package-lock.json b/package-lock.json index 6392ca6..ea1f6a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,17 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "express": "^4.21.0" + "express": "^4.21.0", + "jsonwebtoken": "^9.0.3" }, "devDependencies": { "@types/express": "^4.17.21", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.9.0", + "@types/supertest": "^7.2.0", "jest": "^29.7.0", + "supertest": "^7.2.2", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", "typescript": "^5.6.3" @@ -963,6 +967,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -1084,6 +1111,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -1165,6 +1199,24 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1172,13 +1224,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1251,6 +1309,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -1386,6 +1468,20 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1603,7 +1699,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1641,6 +1736,12 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1855,6 +1956,29 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1905,6 +2029,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -1983,6 +2114,16 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2012,6 +2153,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -2056,6 +2208,15 @@ "xtend": "^4.0.0" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2138,6 +2299,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2290,6 +2467,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2345,6 +2529,41 @@ "node": ">=8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2572,6 +2791,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2914,7 +3149,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -3559,6 +3793,67 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -3599,6 +3894,42 @@ "node": ">=8" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -3606,6 +3937,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4623,6 +4960,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4788,7 +5209,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -4937,7 +5357,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 4522e40..bb0cda3 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,17 @@ }, "license": "MIT", "dependencies": { - "express": "^4.21.0" + "express": "^4.21.0", + "jsonwebtoken": "^9.0.3" }, "devDependencies": { "@types/express": "^4.17.21", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.9.0", + "@types/supertest": "^7.2.0", "jest": "^29.7.0", + "supertest": "^7.2.2", "ts-jest": "^29.2.5", "ts-node-dev": "^2.0.0", "typescript": "^5.6.3" diff --git a/src/ROLE_BASED_AUTH.md b/src/ROLE_BASED_AUTH.md new file mode 100644 index 0000000..fcb31c2 --- /dev/null +++ b/src/ROLE_BASED_AUTH.md @@ -0,0 +1,213 @@ +# Role-Based Authorization — Technical Reference + +> **Branch:** `feature/backend-06-role-based-authorization` +> **Scope:** `Talenttrust/Talenttrust-Backend` + +--- + +## Overview + +The authorization system enforces role-scoped permissions across all Talenttrust API endpoints. It is implemented as a three-layer stack: + +``` +HTTP Request + │ + ▼ +requireAuth ← verifies JWT with jsonwebtoken, attaches req.user + │ + ▼ +requireRole ← coarse-grained: is the user's role in the allowed set? + OR +requirePermission ← fine-grained: does the matrix allow this action? ownOnly? + │ + ▼ +Route Handler +``` + +--- + +## Roles + +| Role | Description | +|--------------|----------------------------------------------------------| +| `admin` | Full platform access. No ownership restrictions. | +| `client` | Posts jobs, manages contracts, makes payments. | +| `freelancer` | Browses jobs, submits proposals, fulfils contracts. | + +Role values are validated against a readonly `ALL_ROLES` allowlist on every request. Unknown strings — including plausible forgeries like `"superadmin"` or `"ADMIN"` — are always rejected with HTTP 401. The `exp` claim is enforced by `jsonwebtoken` automatically. + +--- + +## Permission Matrix + +Permissions are declared as a readonly constant array (`PERMISSION_MATRIX`) in `src/types/roles.ts`. Each entry is: + +```ts +{ + role: Role; // "admin" | "client" | "freelancer" + resource: Resource; // "jobs" | "proposals" | "contracts" | ... + action: Action; // "create" | "read" | "update" | "delete" | "list" + ownOnly?: boolean; // if true, user.id must equal the record's owner id +} +``` + +### Quick-reference (non-admin roles) + +| Resource | Action | client | freelancer | +|-------------|----------|-----------------|-----------------| +| jobs | create | ✅ | ❌ | +| jobs | read | ✅ | ✅ | +| jobs | update | ✅ (own only) | ❌ | +| jobs | delete | ✅ (own only) | ❌ | +| jobs | list | ✅ | ✅ | +| proposals | create | ❌ | ✅ | +| proposals | read | ✅ (own only) | ✅ (own only) | +| proposals | update | ❌ | ✅ (own only) | +| proposals | delete | ❌ | ✅ (own only) | +| contracts | create | ✅ | ❌ | +| contracts | read | ✅ (own only) | ✅ (own only) | +| payments | create | ✅ | ❌ | +| payments | read | ✅ (own only) | ✅ (own only) | +| reviews | create | ✅ | ✅ | +| reviews | update | ✅ (own only) | ✅ (own only) | +| reports | * | ❌ | ❌ | +| users | * | ❌ | ❌ | +| settings | read | ✅ (own only) | ✅ (own only) | +| settings | update | ✅ (own only) | ✅ (own only) | + +Admin has unrestricted access to every resource and action (no `ownOnly` entries). + +--- + +## Middleware API + +### `requireAuth` + +```ts +import { requireAuth } from "./middleware/authorization"; + +router.get("/jobs", requireAuth, handler); +``` + +Validates `Authorization: Bearer ` using `jwt.verify()` against `JWT_SECRET` (from `process.env`). Reads `sub`, `email`, and `role` directly from the decoded payload. On success, attaches `req.user: AuthenticatedUser`. Responds 401 on any failure, including expired tokens (distinguished by the message `"Token has expired."`). + +**Required JWT payload:** +```json +{ + "sub": "", + "email": "", + "role": "admin" | "client" | "freelancer", + "exp": +} +``` + +**Environment variable required:** `JWT_SECRET` — the HMAC secret used to sign and verify all tokens. + +--- + +### `requireRole(...roles)` + +```ts +import { requireRole } from "./middleware/authorization"; + +// Admin only +router.get("/admin/reports", requireAuth, requireRole("admin"), handler); + +// Admin or client +router.get("/contracts", requireAuth, requireRole("admin", "client"), handler); +``` + +Coarse-grained check. Responds 403 when `req.user.role` is not in the allowed list. +Must come after `requireAuth`. + +--- + +### `requirePermission(resource, action, [resolver])` + +```ts +import { requirePermission } from "./middleware/authorization"; + +// No ownership check needed (all matching permissions are unrestricted for the role) +router.get("/jobs", requireAuth, requirePermission("jobs", "list"), handler); + +// Ownership check — resolver fetches the record's owner id from the DB +router.patch( + "/jobs/:id", + requireAuth, + requirePermission("jobs", "update", (req) => jobService.getOwnerId(req.params.id)), + handler, +); +``` + +Fine-grained check against `PERMISSION_MATRIX`. + +| Resolver return value | Outcome | +|-----------------------|--------------------------------------------------| +| `string` (owner id) | Ownership comparison runs; grant or 403 | +| `null` | Record not found → **404** (hides existence) | +| throws | Server error → **500** | +| omitted | ownOnly permissions are always denied | + +Responds 403 on denial. Must come after `requireAuth`. + +--- + +## Security Notes + +### Threat Model + +| Threat | Mitigation | +|---------------------------------|---------------------------------------------------------------------------------| +| Token forgery / manipulation | Tokens verified cryptographically by `jwt.verify()` with `JWT_SECRET` (HS256); any tampered payload breaks the HMAC signature and is rejected before claims are read. | +| Token replay after expiry | `jwt.verify()` enforces the `exp` claim automatically; expired tokens are rejected with a distinct 401 message. | +| Privilege escalation via role | Role values are compared against a readonly constant allowlist after decode — not a DB query that could be manipulated. | +| Horizontal privilege escalation | `ownOnly` evaluated against a **DB-sourced** owner id, never against request input. | +| Resource existence leakage | When the resolver returns `null` the middleware returns 404 (not 403), preventing attackers from enumerating which records exist. | +| Internal error leakage | Catch blocks return the minimum safe error string; stack traces and internal ids are never surfaced. | +| 401 vs 403 confusion | 401 = "who are you?", 403 = "I know who you are, but no". Both are applied correctly throughout. | + +### What is NOT handled here + +- Rate limiting (should be applied at the gateway or a separate middleware). +- Input validation / sanitisation (use a schema validator like `zod` before the auth middleware). +- Audit logging (instrument the route handlers or a separate middleware after auth passes). + +--- + +## File Map + +``` +src/ +├── lib/ +│ └── types.ts ← ALL_ROLES, PERMISSION_MATRIX, AuthenticatedUser +├── lib/ +│ ├── authorization.ts ← Pure engine: isAuthorized(), isValidRole() +│ ├── data.ts ← PERMISSION_MATRIX +│ ├── types.ts ← ALL_ROLES, AuthenticatedRequest, User +├── middleware/ +│ ├── authorization.ts ← requireAuth, requireRole, requirePermission +│ └── __tests__/ +│ └── authorization.test.ts ← Integration tests (supertest + real jwt.sign tokens) +└── routes/ + └── index.ts ← Reference wiring for all protected routes +``` + +--- + +## Running Tests + +```bash +npm install +npm test # all tests +npm run test:coverage # with HTML coverage report +npm run test:ci # CI mode: serial, 95% threshold enforced, fails on threshold miss +``` + +Coverage thresholds (enforced in `jest.config.ts`): + +| Metric | Threshold | +|------------|-----------| +| Branches | 95% | +| Functions | 95% | +| Lines | 95% | +| Statements | 95% | \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index dd2fd8f..9e37ee1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'ok', service: 'talenttrust-backend' }); }); -app.get('/api/v1/contracts', (_req: Request, res: Response) => { +app.get('/api/v1/contracts', (_req: Request, res: Response) => { res.json({ contracts: [] }); }); diff --git a/src/lib/authorization.ts b/src/lib/authorization.ts new file mode 100644 index 0000000..4f7bd77 --- /dev/null +++ b/src/lib/authorization.ts @@ -0,0 +1,148 @@ +import type { Action, Permission, Resource, Role, User } from "./types"; + +// ─── Role allowlist ─────────────────────────────────────────────────────────── + +const ALL_ROLES: ReadonlySet = new Set(["admin", "client", "freelancer"]); + +/** + * Type-guard / validator for role claim values coming out of a JWT. + * Returns true only for the three platform roles; rejects any other string. + */ +export function isValidRole(value: unknown): value is Role { + return typeof value === "string" && ALL_ROLES.has(value); +} + +// ─── Permission matrix ──────────────────────────────────────────────────────── + +/** + * The authoritative permission matrix for the platform. + * + * Reads top-to-bottom: + * "An may [only when they own the record]." + * + * Design decisions: + * - admin has unrestricted access to every resource and action. + * - client can manage jobs/contracts they own, read public proposals, pay. + * - freelancer can propose on jobs, manage their own proposals/contracts. + * - Reviews are readable by all roles but writable only by their author. + */ +export const PERMISSION_MATRIX: readonly Permission[] = [ + // ── admin (full access, no ownOnly restrictions) ────────────────────────── + ...( + ["users", "jobs", "proposals", "contracts", "payments", "reviews", "reports", "settings"] as Resource[] + ).flatMap((resource) => + (["create", "read", "update", "delete", "list"] as Action[]).map( + (action): Permission => ({ role: "admin", resource, action }) + ) + ), + + // ── client ──────────────────────────────────────────────────────────────── + { role: "client", resource: "jobs", action: "create" }, + { role: "client", resource: "jobs", action: "read" }, + { role: "client", resource: "jobs", action: "update", ownOnly: true }, + { role: "client", resource: "jobs", action: "delete", ownOnly: true }, + { role: "client", resource: "jobs", action: "list" }, + { role: "client", resource: "proposals", action: "read", ownOnly: true }, + { role: "client", resource: "proposals", action: "list", ownOnly: true }, + { role: "client", resource: "contracts", action: "create" }, + { role: "client", resource: "contracts", action: "read", ownOnly: true }, + { role: "client", resource: "contracts", action: "update", ownOnly: true }, + { role: "client", resource: "contracts", action: "list", ownOnly: true }, + { role: "client", resource: "payments", action: "create" }, + { role: "client", resource: "payments", action: "read", ownOnly: true }, + { role: "client", resource: "payments", action: "list", ownOnly: true }, + { role: "client", resource: "reviews", action: "create" }, + { role: "client", resource: "reviews", action: "read" }, + { role: "client", resource: "reviews", action: "update", ownOnly: true }, + { role: "client", resource: "reviews", action: "list" }, + { role: "client", resource: "settings", action: "read", ownOnly: true }, + { role: "client", resource: "settings", action: "update", ownOnly: true }, + + // ── freelancer ──────────────────────────────────────────────────────────── + { role: "freelancer", resource: "jobs", action: "read" }, + { role: "freelancer", resource: "jobs", action: "list" }, + { role: "freelancer", resource: "proposals", action: "create" }, + { role: "freelancer", resource: "proposals", action: "read", ownOnly: true }, + { role: "freelancer", resource: "proposals", action: "update", ownOnly: true }, + { role: "freelancer", resource: "proposals", action: "delete", ownOnly: true }, + { role: "freelancer", resource: "proposals", action: "list", ownOnly: true }, + { role: "freelancer", resource: "contracts", action: "read", ownOnly: true }, + { role: "freelancer", resource: "contracts", action: "update", ownOnly: true }, + { role: "freelancer", resource: "contracts", action: "list", ownOnly: true }, + { role: "freelancer", resource: "payments", action: "read", ownOnly: true }, + { role: "freelancer", resource: "payments", action: "list", ownOnly: true }, + { role: "freelancer", resource: "reviews", action: "create" }, + { role: "freelancer", resource: "reviews", action: "read" }, + { role: "freelancer", resource: "reviews", action: "update", ownOnly: true }, + { role: "freelancer", resource: "reviews", action: "list" }, + { role: "freelancer", resource: "settings", action: "read", ownOnly: true }, + { role: "freelancer", resource: "settings", action: "update", ownOnly: true }, +] as const; + +// ─── isAuthorized ───────────────────────────────────────────────────────────── + +interface AuthorizationInput { + user: User; + resource: Resource; + action: Action; + /** + * The id of the user who owns the target record. + * Required when the matching permission entry has `ownOnly: true`. + * For admin the value is ignored — admins always pass. + */ + resourceOwnerId?: string; +} + +interface AuthorizationResult { + granted: boolean; + reason: string; +} + +/** + * Evaluates whether `user` may perform `action` on `resource`. + * + * Logic: + * 1. Find the matching permission entry (role + resource + action). + * 2. If no entry exists → denied. + * 3. If the entry has no `ownOnly` flag → granted. + * 4. If `ownOnly: true` and the user is an admin → granted (admins are exempt). + * 5. If `ownOnly: true` and `resourceOwnerId` is absent → denied + * (caller did not supply a resolver, treat as no-access). + * 6. If `ownOnly: true` and `resourceOwnerId !== user.id` → denied. + * 7. Otherwise → granted. + */ +export function isAuthorized({ + user, + resource, + action, + resourceOwnerId, +}: AuthorizationInput): AuthorizationResult { + const entry = PERMISSION_MATRIX.find( + (p) => p.role === user.role && p.resource === resource && p.action === action + ); + + if (!entry) { + return { granted: false, reason: `Role '${user.role}' may not '${action}' '${resource}'.` }; + } + + // No ownership restriction on this entry. + if (!entry.ownOnly) { + return { granted: true, reason: "Permission granted." }; + } + + // Admins are always exempt from ownership checks. + if (user.role === "admin") { + return { granted: true, reason: "Admin bypass." }; + } + + // Ownership check required but no owner id was resolved. + if (resourceOwnerId === undefined) { + return { granted: false, reason: "Ownership could not be verified." }; + } + + if (resourceOwnerId !== user.id) { + return { granted: false, reason: "Resource is owned by a different user." }; + } + + return { granted: true, reason: "Permission granted (owner)." }; +} \ No newline at end of file diff --git a/src/lib/data.ts b/src/lib/data.ts new file mode 100644 index 0000000..59f0c41 --- /dev/null +++ b/src/lib/data.ts @@ -0,0 +1,65 @@ +import { Action, Permission, Resource } from "./types"; + +/** + * The authoritative permission matrix for the platform. + * + * Reads top-to-bottom: + * "An may [only when they own the record]." + * + * Design decisions: + * - admin has unrestricted access to every resource and action. + * - client can manage jobs/contracts they own, read public proposals, pay. + * - freelancer can propose on jobs, manage their own proposals/contracts. + * - Reviews are readable by all roles but writable only by their author. + */ +export const PERMISSION_MATRIX: readonly Permission[] = [ + // ── admin (full access, no ownOnly restrictions) ────────────────────────── + ...( ["users","jobs","proposals","contracts","payments","reviews","reports","settings"] as Resource[]).flatMap( + (resource) => + (["create","read","update","delete","list"] as Action[]).map( + (action): Permission => ({ role: "admin", resource, action }) + ) + ), + + // ── client ──────────────────────────────────────────────────────────────── + { role: "client", resource: "jobs", action: "create" }, + { role: "client", resource: "jobs", action: "read" }, + { role: "client", resource: "jobs", action: "update", ownOnly: true }, + { role: "client", resource: "jobs", action: "delete", ownOnly: true }, + { role: "client", resource: "jobs", action: "list" }, + { role: "client", resource: "proposals", action: "read", ownOnly: true }, + { role: "client", resource: "proposals", action: "list", ownOnly: true }, + { role: "client", resource: "contracts", action: "create" }, + { role: "client", resource: "contracts", action: "read", ownOnly: true }, + { role: "client", resource: "contracts", action: "update", ownOnly: true }, + { role: "client", resource: "contracts", action: "list", ownOnly: true }, + { role: "client", resource: "payments", action: "create" }, + { role: "client", resource: "payments", action: "read", ownOnly: true }, + { role: "client", resource: "payments", action: "list", ownOnly: true }, + { role: "client", resource: "reviews", action: "create" }, + { role: "client", resource: "reviews", action: "read" }, + { role: "client", resource: "reviews", action: "update", ownOnly: true }, + { role: "client", resource: "reviews", action: "list" }, + { role: "client", resource: "settings", action: "read", ownOnly: true }, + { role: "client", resource: "settings", action: "update", ownOnly: true }, + + // ── freelancer ──────────────────────────────────────────────────────────── + { role: "freelancer", resource: "jobs", action: "read" }, + { role: "freelancer", resource: "jobs", action: "list" }, + { role: "freelancer", resource: "proposals", action: "create" }, + { role: "freelancer", resource: "proposals", action: "read", ownOnly: true }, + { role: "freelancer", resource: "proposals", action: "update", ownOnly: true }, + { role: "freelancer", resource: "proposals", action: "delete", ownOnly: true }, + { role: "freelancer", resource: "proposals", action: "list", ownOnly: true }, + { role: "freelancer", resource: "contracts", action: "read", ownOnly: true }, + { role: "freelancer", resource: "contracts", action: "update", ownOnly: true }, + { role: "freelancer", resource: "contracts", action: "list", ownOnly: true }, + { role: "freelancer", resource: "payments", action: "read", ownOnly: true }, + { role: "freelancer", resource: "payments", action: "list", ownOnly: true }, + { role: "freelancer", resource: "reviews", action: "create" }, + { role: "freelancer", resource: "reviews", action: "read" }, + { role: "freelancer", resource: "reviews", action: "update", ownOnly: true }, + { role: "freelancer", resource: "reviews", action: "list" }, + { role: "freelancer", resource: "settings", action: "read", ownOnly: true }, + { role: "freelancer", resource: "settings", action: "update", ownOnly: true }, +] as const; \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..d424b4d --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,43 @@ +import type { Request } from "express"; + +// ─── Role ───────────────────────────────────────────────────────────────────── + +export type Role = "admin" | "client" | "freelancer"; + +// ─── Resource & Action ─────────────────────────────────────────────────────── + +export type Resource = + | "users" + | "jobs" + | "proposals" + | "contracts" + | "payments" + | "reviews" + | "reports" + | "settings"; + +export type Action = "create" | "read" | "update" | "delete" | "list"; + +// ─── Permission (one row of the PERMISSION_MATRIX) ─────────────────────────── + +export interface Permission { + role: Role; + resource: Resource; + action: Action; + /** When true, the action is only allowed if the caller owns the record. */ + ownOnly?: boolean; +} + +// ─── User (attached to req.user by requireAuth) ────────────────────────────── + +export interface User { + id: string; + email: string; + role: Role; +} + +// ─── AuthenticatedRequest ──────────────────────────────────────────────────── + +export interface AuthenticatedRequest extends Request { + user?: User; +} \ No newline at end of file diff --git a/src/middleware/__tests__/authorization.test.ts b/src/middleware/__tests__/authorization.test.ts new file mode 100644 index 0000000..9547274 --- /dev/null +++ b/src/middleware/__tests__/authorization.test.ts @@ -0,0 +1,169 @@ + + +// ─── Set secret BEFORE any other imports so the middleware picks it up ──────── +process.env.JWT_SECRET = "talenttrust-test-secret"; + +import express, { type Request, type Response } from "express"; +import request from "supertest"; +import jwt from "jsonwebtoken"; +import { + requireAuth, + requireRole, + requirePermission, +} from "../authorization"; +import { AuthenticatedRequest } from "../../lib/types"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const SECRET = process.env.JWT_SECRET || 'your-jwt-secret'; +const WRONG_SECRET = "wrong-secret"; + +// ─── Token helpers ──────────────────────────────────────────────────────────── + +/** + * Signs a valid JWT. `sub` lives only in the payload — never in options.subject + * — to avoid the "payload already has sub property" error from jsonwebtoken. + */ +function makeToken( + role: string, + sub = "user-1", + opts: { secret?: string; expiresIn?: string | number } = {} +): string { + return jwt.sign( + { sub, email: "test@tt.com", role }, + opts.secret ?? SECRET, + { expiresIn: (opts.expiresIn ?? "1h") as any } + ); +} + +const adminToken = () => makeToken("admin", "admin-1"); +const clientToken = (id = "client-1") => makeToken("client", id); +const freelancerToken = (id = "free-1") => makeToken("freelancer", id); + +function bearer(token: string) { + return { Authorization: `Bearer ${token}` }; +} + +// App factory + +function makeApp( + middlewares: any[], + handler = (_req: Request, res: Response) => res.json({ ok: true }) +) { + const app = express(); + app.use(express.json()); + app.get("/test", ...middlewares, handler); + return app; +} + + +it("requireAuth: valid token → 200 and req.user populated", async () => { + const app = makeApp( + [requireAuth], + (req: AuthenticatedRequest, res: Response) => res.json({ user: req.user }) + ); + const res = await request(app).get("/test").set(bearer(clientToken("client-abc"))); + expect(res.status).toBe(200); + expect(res.body.user).toMatchObject({ id: "client-abc", role: "client" }); +}); + + +it("requireAuth: missing Authorization header → 401", async () => { + const app = makeApp([requireAuth]); + const res = await request(app).get("/test"); + expect(res.status).toBe(401); + expect(res.body).toHaveProperty("error"); +}); + +it("requireAuth: token signed with wrong secret → 401", async () => { + const forged = makeToken("admin", "attacker", { secret: WRONG_SECRET }); + const app = makeApp([requireAuth]); + const res = await request(app).get("/test").set(bearer(forged)); + expect(res.status).toBe(401); +}); + + +it("requireAuth: expired token → 401 with expiry message", async () => { + const expired = makeToken("admin", "user-1", { expiresIn: -1 }); + const app = makeApp([requireAuth]); + const res = await request(app).get("/test").set(bearer(expired)); + expect(res.status).toBe(401); + expect(res.body.error).toMatch(/expired/i); +}); + +it("requireAuth: token with unknown role → 401", async () => { + const badRole = makeToken("superadmin", "u-1"); + const app = makeApp([requireAuth]); + const res = await request(app).get("/test").set(bearer(badRole)); + expect(res.status).toBe(401); +}); + + +it("requireRole: matching role → 200; non-matching role → 403", async () => { + const adminApp = makeApp([requireAuth, requireRole("admin")]); + const freelancerApp = makeApp([requireAuth, requireRole("admin")]); + + const pass = await request(adminApp).get("/test").set(bearer(adminToken())); + expect(pass.status).toBe(200); + + const fail = await request(freelancerApp).get("/test").set(bearer(freelancerToken())); + expect(fail.status).toBe(403); + expect(fail.body).toHaveProperty("error"); +}); + + +it("requireRole: called without requireAuth → 401", async () => { + const app = makeApp([requireRole("admin")]); + const res = await request(app).get("/test"); + expect(res.status).toBe(401); +}); + +it("requirePermission: client may list jobs; freelancer may not create jobs", async () => { + const listApp = makeApp([requireAuth, requirePermission("jobs", "list")]); + const createApp = makeApp([requireAuth, requirePermission("jobs", "create")]); + + const pass = await request(listApp).get("/test").set(bearer(clientToken())); + expect(pass.status).toBe(200); + + const fail = await request(createApp).get("/test").set(bearer(freelancerToken())); + expect(fail.status).toBe(403); +}); + + +it("requirePermission ownOnly: owner → 200; non-owner → 403; missing record → 404", async () => { + const ownerResolver = jest.fn().mockResolvedValue("client-1"); + const nonOwnerResolver = jest.fn().mockResolvedValue("someone-else"); + const missingResolver = jest.fn().mockResolvedValue(null); + + const app = (resolver: any) => + makeApp([requireAuth, requirePermission("jobs", "update", resolver)]); + + const own = await request(app(ownerResolver)).get("/test").set(bearer(clientToken("client-1"))); + expect(own.status).toBe(200); + + const deny = await request(app(nonOwnerResolver)).get("/test").set(bearer(clientToken("client-1"))); + expect(deny.status).toBe(403); + + const miss = await request(app(missingResolver)).get("/test").set(bearer(clientToken("client-1"))); + expect(miss.status).toBe(404); +}); + +it("security: 403 and 401 responses do not leak ids or token contents", async () => { + // 403 must not contain either user id + const resolver = jest.fn().mockResolvedValue("owner-secret-id"); + const forbiddenApp = makeApp([requireAuth, requirePermission("jobs", "update", resolver)]); + const forbiddenRes = await request(forbiddenApp) + .get("/test") + .set(bearer(clientToken("requester-secret-id"))); + expect(forbiddenRes.status).toBe(403); + const forbiddenBody = JSON.stringify(forbiddenRes.body); + expect(forbiddenBody).not.toContain("requester-secret-id"); + expect(forbiddenBody).not.toContain("owner-secret-id"); + + // 401 must not echo the raw token back + const rawToken = makeToken("admin", "u1", { secret: WRONG_SECRET }); + const unauthorizedApp = makeApp([requireAuth]); + const unauthorizedRes = await request(unauthorizedApp).get("/test").set(bearer(rawToken)); + expect(unauthorizedRes.status).toBe(401); + expect(JSON.stringify(unauthorizedRes.body)).not.toContain(rawToken); +}); \ No newline at end of file diff --git a/src/middleware/authorization.ts b/src/middleware/authorization.ts new file mode 100644 index 0000000..5c6800b --- /dev/null +++ b/src/middleware/authorization.ts @@ -0,0 +1,250 @@ +/** + * Express middleware factories for authentication and role-based authorization. + * + * Middleware stack order for a protected route: + * requireAuth → requireRole | requirePermission + * + * `requireAuth` must always run first. It validates the bearer token with + * `jsonwebtoken`, reads the user's id/email/role directly from the JWT payload, + * and attaches a `User` object to `req.user`. The downstream middleware + * factories trust that `req.user` is present and well-formed once + * `requireAuth` has passed. + * + * Expected JWT payload shape: + * ```json + * { + * "sub": "", + * "email": "", + * "role": "admin" | "client" | "freelancer", + * "iat": , + * "exp": + * } + * ``` + * + * @security + * - Tokens are verified with `JWT_SECRET` using HS256; forged or tampered + * tokens are rejected at the HMAC-verification step before any claims + * are read. + * - `jwt.verify()` also enforces the `exp` claim — expired tokens are + * rejected without any additional check. + * - Role values are re-validated against the ALL_ROLES allowlist after + * decode so that a token carrying an arbitrary role string is always caught. + * - `resourceOwnerId` must come from a trusted database lookup — never from + * request parameters supplied by the caller. + * - On any error the middleware responds with the minimum diagnostic + * information (no stack traces, no internal ids) to limit information + * leakage to attackers. + */ + +import type { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; +import { isAuthorized, isValidRole } from "../lib/authorization"; +import type { Action, User, Resource, Role, AuthenticatedRequest } from "../lib/types"; + +// ─── JWT configuration ──────────────────────────────────────────────────────── + +const JWT_SECRET = process.env.JWT_SECRET ?? ""; + +/** + * Shape of the decoded JWT payload expected by this platform. + * `sub` carries the user id (standard JWT claim). + */ +interface JwtPayload { + sub: string; + email: string; + role: unknown; // validated against ALL_ROLES before use + iat?: number; + exp?: number; +} + +// ─── Error response helpers ─────────────────────────────────────────────────── + +function unauthorized(res: Response, message = "Unauthorized"): void { + res.status(401).json({ error: message }); +} + +function forbidden(res: Response, message = "Forbidden"): void { + res.status(403).json({ error: message }); +} + +// ─── requireAuth ───────────────────────────────────────────────────────────── + +/** + * Authentication middleware. + * + * Validates the `Authorization: Bearer ` header using `jsonwebtoken`. + * On success, attaches a typed `User` to `req.user`. + * + * Failure cases (all → HTTP 401): + * - Missing or malformed `Authorization` header + * - Token signature invalid (wrong secret / tampered payload) + * - Token expired (`exp` claim in the past) + * - Token missing required claims (`sub`, `email`, `role`) + * - `role` claim is not a member of ALL_ROLES + * + * @example + * router.get("/jobs", requireAuth, handler); + */ +export function requireAuth( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith("Bearer ")) { + unauthorized(res, "Missing or malformed Authorization header."); + return; + } + + const token = authHeader.slice(7); // strip "Bearer " + + try { + // jwt.verify throws for any invalid token (bad signature, expired, etc.) + const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload; + + // Guard required claims — a well-formed token always carries these. + if (!decoded.sub || !decoded.email) { + unauthorized(res, "Token is missing required claims."); + return; + } + + // Re-validate the role claim against the platform allowlist. + if (!isValidRole(decoded.role)) { + unauthorized(res, "Token carries an unrecognised role."); + return; + } + + req.user = { + id: decoded.sub, + email: decoded.email, + role: decoded.role, + } satisfies User; + + next(); + } catch (err) { + if (err instanceof jwt.TokenExpiredError) { + unauthorized(res, "Token has expired."); + return; + } + // Covers JsonWebTokenError (bad signature, malformed) and NotBeforeError. + unauthorized(res, "Invalid token."); + } +} + +// ─── requireRole ───────────────────────────────────────────────────────────── + +/** + * Authorization middleware factory — coarse-grained role check. + * + * Must be placed after `requireAuth` in the middleware chain. + * + * @param allowedRoles - One or more roles that may access the route. + * @returns Express middleware that responds with 403 when the user's role is + * not in `allowedRoles`. + * + * @example + * router.get("/reports", requireAuth, requireRole("admin"), handler); + */ +export function requireRole(...allowedRoles: Role[]) { + return function roleMiddleware( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): void { + if (!req.user) { + unauthorized(res, "Authentication required."); + return; + } + + if (!allowedRoles.includes(req.user.role)) { + forbidden(res, "You do not have permission to access this resource."); + return; + } + + next(); + }; +} + +// ─── requirePermission ──────────────────────────────────────────────────────── + +/** + * Authorization middleware factory — fine-grained permission check. + * + * Delegates to `isAuthorized` which evaluates the PERMISSION_MATRIX including + * `ownOnly` restrictions. When a permission entry carries `ownOnly: true`, + * the middleware calls `getResourceOwnerId` to resolve the true owner of the + * target record from the database. + * + * Must be placed after `requireAuth` in the middleware chain. + * + * @param resource - The resource domain being accessed. + * @param action - The action being performed. + * @param getResourceOwnerId - Optional async resolver returning the userId of + * the record owner, or `null` if the record does + * not exist. + * @returns Express middleware. + * + * @example + * router.get("/jobs", requireAuth, requirePermission("jobs", "list"), handler); + * + * @example + * router.delete( + * "/jobs/:id", + * requireAuth, + * requirePermission("jobs", "delete", (req) => + * jobService.getOwnerIdById(req.params.id) + * ), + * deleteJobHandler, + * ); + */ +export function requirePermission( + resource: Resource, + action: Action, + getResourceOwnerId?: (req: AuthenticatedRequest) => Promise +) { + return async function permissionMiddleware( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + if (!req.user) { + unauthorized(res, "Authentication required."); + return; + } + + try { + let resourceOwnerId: string | undefined; + + if (getResourceOwnerId) { + const ownerId = await getResourceOwnerId(req); + + if (ownerId === null) { + // Record does not exist — return 404 rather than leaking whether + // the record exists but is forbidden. + res.status(404).json({ error: "Resource not found." }); + return; + } + + resourceOwnerId = ownerId; + } + + const result = isAuthorized({ + user: req.user, + resource, + action, + resourceOwnerId, + }); + + if (!result.granted) { + forbidden(res, "You do not have permission to perform this action."); + return; + } + + next(); + } catch (err) { + // Resolver threw — treat as a server error, not an auth failure. + res.status(500).json({ error: "Authorization check failed." }); + } + }; +} \ No newline at end of file From b4a9585835943037684295ab31e42f2da3e22a9c Mon Sep 17 00:00:00 2001 From: blessme247 Date: Tue, 24 Mar 2026 07:46:33 +0100 Subject: [PATCH 2/4] feat: role based auth with docs for reference --- {src => docs/backend}/ROLE_BASED_AUTH.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {src => docs/backend}/ROLE_BASED_AUTH.md (100%) diff --git a/src/ROLE_BASED_AUTH.md b/docs/backend/ROLE_BASED_AUTH.md similarity index 100% rename from src/ROLE_BASED_AUTH.md rename to docs/backend/ROLE_BASED_AUTH.md From f044318f99f9dafacdab2e10a76c652f8dde1076 Mon Sep 17 00:00:00 2001 From: blessme247 Date: Tue, 24 Mar 2026 22:15:22 +0100 Subject: [PATCH 3/4] feat: implement rate limiting and abuse guard with tests and docs --- docs/backend/RATE_LIMITING.md | 182 ++++++++++ src/index.ts | 14 + src/lib/rateLimitStore.ts | 96 ++++++ .../__tests__/rateLimitStore.test.ts | 166 +++++++++ src/middleware/__tests__/rateLimiter.test.ts | 318 ++++++++++++++++++ src/middleware/rateLimiter.ts | 262 +++++++++++++++ 6 files changed, 1038 insertions(+) create mode 100644 docs/backend/RATE_LIMITING.md create mode 100644 src/lib/rateLimitStore.ts create mode 100644 src/middleware/__tests__/rateLimitStore.test.ts create mode 100644 src/middleware/__tests__/rateLimiter.test.ts create mode 100644 src/middleware/rateLimiter.ts diff --git a/docs/backend/RATE_LIMITING.md b/docs/backend/RATE_LIMITING.md new file mode 100644 index 0000000..e76e3ab --- /dev/null +++ b/docs/backend/RATE_LIMITING.md @@ -0,0 +1,182 @@ +# Rate Limiting & Abuse Guard + +**Location:** `src/middleware/rateLimiter.ts` + `src/lib/rateLimitStore.ts` +**Feature branch:** `feature/backend-10-rate-limiting-and-abuse-guard` + +--- + +## Overview + +This document describes the rate-limiting and abuse-guard system implemented for TalentTrust's public API. The implementation uses a **sliding-window counter** algorithm with an **adaptive abuse guard** that applies exponential back-off blocks to repeat offenders. + +--- + +## Architecture + +``` +Request + │ + ▼ +┌──────────────────────────────────────────────┐ +│ rateLimiterMiddleware (Express middleware) │ +│ │ +│ 1. Extract key (IP / custom keyFn) │ +│ 2. Check hard-block → 429 if blocked │ +│ 3. Sliding-window counter (RateLimitStore) │ +│ 4. Limit exceeded? │ +│ ├─ No → set headers, call next() │ +│ └─ Yes → check abuse threshold │ +│ ├─ Below → 429 + Retry-After │ +│ └─ At/above → hard-block + 429 │ +└──────────────────────────────────────────────┘ +``` + +--- + +## Algorithm + +### Sliding Window Counter + +- Each unique key (default: client IP) gets a counter and a `windowStart` timestamp. +- On each request, if `now - windowStart > windowMs`, the window resets to `now` with `count = 0`. +- `count` is incremented before the limit check, so the check is `count > maxRequests`. + +### Abuse Guard + +| Event | Action | +|---|---| +| `count > maxRequests` | Violation recorded | +| violations in `blockWindowMs` reaches `abuseThreshold` | Key is hard-blocked for `blockDurationMs` | +| Subsequent abuse after unblocking | Block duration **doubles** (exponential back-off), capped at `maxBlockDurationMs` | + +--- + +## Configuration Reference + +All options are passed to `createRateLimiter(config)`. +Environment variables (set in `.env` or process environment) override defaults for the `index.ts` instance. + +| Option | Env var | Default | Description | +|---|---|---|---| +| `maxRequests` | `RATE_LIMIT_MAX_REQUESTS` | `100` | Max requests per window | +| `windowMs` | `RATE_LIMIT_WINDOW_MS` | `60000` | Window size in ms | +| `abuseThreshold` | `RATE_LIMIT_ABUSE_THRESHOLD` | `5` | Violations before hard-block | +| `blockWindowMs` | – | `300000` | Observation window for violations | +| `blockDurationMs` | `RATE_LIMIT_BLOCK_MS` | `600000` | Initial block duration | +| `maxBlockDurationMs` | – | `86400000` | Maximum block duration (24 h) | +| `keyFn` | – | IP extraction | Custom key derivation function | +| `sendHeaders` | – | `true` | Emit `X-RateLimit-*` headers | +| `store` | – | new instance | Shared `RateLimitStore` | + +--- + +## Response Headers + +| Header | When | Value | +|---|---|---| +| `X-RateLimit-Limit` | Always (if `sendHeaders`) | Configured `maxRequests` | +| `X-RateLimit-Remaining` | Always (if `sendHeaders`) | Requests left in window | +| `X-RateLimit-Reset` | Always (if `sendHeaders`) | Seconds until window resets | +| `Retry-After` | 429 responses | Seconds to wait | +| `X-RateLimit-Blocked` | Hard-block 429 | `"true"` | + +--- + +## Response Bodies (429) + +**Rate limit exceeded (not yet blocked):** +```json +{ + "error": "Too Many Requests", + "message": "Rate limit exceeded. Try again in 42 second(s).", + "retryAfter": 42 +} +``` + +**Abuse guard – hard block:** +```json +{ + "error": "Too Many Requests", + "message": "Abuse detected. Your access has been temporarily blocked.", + "retryAfter": 600 +} +``` + +--- + +## Security Notes + +1. **Key hashing** – Raw IP addresses are never stored; the store uses SHA-256 hashes. This prevents PII leaking in heap snapshots or memory dumps. +2. **X-Forwarded-For trust** – The default `keyFn` takes the *first* value from `X-Forwarded-For`. In production behind a single reverse proxy, set `app.set('trust proxy', 1)` and supply a `keyFn` that uses `req.ip` to prevent clients spoofing multiple XFF values. +3. **No external dependency** – The store is fully in-process. For multi-instance deployments, replace `RateLimitStore` with a Redis-backed adapter and share it via the `store` option. +4. **Health endpoint excluded** – `/health` is intentionally not rate-limited so load-balancer probes and monitoring agents are never blocked. +5. **Exponential back-off** – Repeat offenders face doubling block durations, significantly raising the cost of sustained abuse. + +--- + +## Threat Model + +| Threat | Mitigation | +|---|---| +| DDoS from single IP | Hard-block after `abuseThreshold` violations; exponential back-off | +| IP spoofing via XFF | Use `trust proxy` + `req.ip`-based `keyFn` in production | +| Memory exhaustion | Background sweep purges expired entries every `windowMs` | +| Heap dump leaking IPs | Keys stored as SHA-256 hashes | +| Clock manipulation | All timing via `Date.now()`; block expiry checked on every request | + +--- + +## Usage Examples + +### Default (IP-based, applied to all `/api/` routes) + +```ts +import { createRateLimiter } from './middleware/rateLimiter'; + +const limiter = createRateLimiter(); // 100 req/min, 5-violation block +app.use('/api/', limiter); +``` + +### Stricter limits for an auth endpoint + +```ts +const authLimiter = createRateLimiter({ + maxRequests: 5, + windowMs: 60_000, + abuseThreshold: 3, + blockDurationMs: 3_600_000, // 1 hour +}); +app.post('/api/v1/auth/login', authLimiter, loginHandler); +``` + +### API-key-scoped limiting + +```ts +const apiKeyLimiter = createRateLimiter({ + maxRequests: 1000, + windowMs: 60_000, + keyFn: (req) => req.headers['x-api-key'] as string ?? req.ip ?? 'unknown', +}); +``` + +### Shared store across multiple limiter instances + +```ts +import { RateLimitStore } from './utils/rateLimitStore'; + +const store = new RateLimitStore(); +const readLimiter = createRateLimiter({ maxRequests: 200, store }); +const writeLimiter = createRateLimiter({ maxRequests: 50, store }); +``` + +--- + +## Running Tests + +```bash +npm install +npm test # run all tests with coverage report +npm run test:coverage # explicit coverage run +``` + +Expected output: ≥ 95 % coverage on branches, functions, lines, and statements for `src/middleware/rateLimiter.ts` and `src/utils/rateLimitStore.ts`. \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 9e37ee1..a8843f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,24 @@ import express, { Request, Response } from 'express'; +import { createRateLimiter } from './middleware/rateLimiter'; const app = express(); const PORT = process.env.PORT || 3001; app.use(express.json()); +// Rate limiting +// Applied only to /api/* routes to leave /health unthrottled for load-balancer +// probes and monitoring. + +const apiLimiter = createRateLimiter({ + maxRequests: Number(process.env.RATE_LIMIT_MAX_REQUESTS) || 100, + windowMs: Number(process.env.RATE_LIMIT_WINDOW_MS) || 60_000, + abuseThreshold: Number(process.env.RATE_LIMIT_ABUSE_THRESHOLD) || 5, + blockDurationMs: Number(process.env.RATE_LIMIT_BLOCK_MS) || 600_000, +}); + +app.use('/api/', apiLimiter); + app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'ok', service: 'talenttrust-backend' }); }); diff --git a/src/lib/rateLimitStore.ts b/src/lib/rateLimitStore.ts new file mode 100644 index 0000000..9432fde --- /dev/null +++ b/src/lib/rateLimitStore.ts @@ -0,0 +1,96 @@ +/** + * @module rateLimitStore + * @description + * In-memory store for rate limiting using a sliding-window counter algorithm. + * + * Each entry tracks: + * - `count` – requests in the current window + * - `windowStart` – epoch ms when the current window began + * - `blocked` – if the key is hard-blocked (abuse guard) + * - `blockedUntil` – epoch ms when the block expires + * + * The store auto-expires stale entries via a periodic sweep to prevent + * unbounded memory growth in production. + * + * @security + * - Keys are hashed before storage to avoid leaking raw IPs in memory dumps. + * - Blocked entries survive the sweep until `blockedUntil` passes. + */ + +import { createHash } from 'crypto'; + +export interface RateLimitEntry { + count: number; + windowStart: number; + blocked: boolean; + blockedUntil: number; +} + +export interface StoreOptions { + /** How often (ms) the GC sweep runs. Default: 60_000 */ + sweepIntervalMs?: number; +} + +export class RateLimitStore { + private readonly store = new Map(); + private sweepTimer: ReturnType | null = null; + + constructor(options: StoreOptions = {}) { + const interval = options.sweepIntervalMs ?? 60_000; + this.sweepTimer = setInterval(() => this.sweep(), interval); + // Allow Node to exit even if this timer is active + if (this.sweepTimer.unref) this.sweepTimer.unref(); + } + + /** + * Derive a stable, opaque key from a raw identifier (e.g. IP address). + * Using SHA-256 prevents raw PII from appearing in heap snapshots. + */ + static hashKey(raw: string): string { + return createHash('sha256').update(raw).digest('hex'); + } + + /** Retrieve an entry or undefined if it doesn't exist. */ + get(rawKey: string): RateLimitEntry | undefined { + return this.store.get(RateLimitStore.hashKey(rawKey)); + } + + /** Upsert an entry. */ + set(rawKey: string, entry: RateLimitEntry): void { + this.store.set(RateLimitStore.hashKey(rawKey), entry); + } + + /** Delete an entry. */ + delete(rawKey: string): void { + this.store.delete(RateLimitStore.hashKey(rawKey)); + } + + /** Total number of tracked keys (for diagnostics). */ + get size(): number { + return this.store.size; + } + + /** + * Remove entries whose windows have expired AND whose block has lifted. + * Called automatically; exposed for testing. + */ + sweep(windowMs = 60_000): void { + const now = Date.now(); + for (const [key, entry] of this.store.entries()) { + const windowExpired = now - entry.windowStart > windowMs; + const blockExpired = !entry.blocked || now > entry.blockedUntil; + if (windowExpired && blockExpired) { + this.store.delete(key); + } + } + } + + /** Stop the background sweep (call in tests / shutdown). */ + destroy(): void { + if (this.sweepTimer) { + clearInterval(this.sweepTimer); + this.sweepTimer = null; + } + this.store.clear(); + } +} \ No newline at end of file diff --git a/src/middleware/__tests__/rateLimitStore.test.ts b/src/middleware/__tests__/rateLimitStore.test.ts new file mode 100644 index 0000000..8c40595 --- /dev/null +++ b/src/middleware/__tests__/rateLimitStore.test.ts @@ -0,0 +1,166 @@ +/** + * @description Unit tests for RateLimitStore. + * + * Coverage targets: + * - key hashing (opaque, deterministic) + * - get / set / delete CRUD + * - sweep removes expired-window + expired-block entries + * - sweep retains entries that are still within window or still blocked + * - destroy stops the timer and clears the store + */ + +import { RateLimitStore, RateLimitEntry } from '../../lib/rateLimitStore'; + +// Helpers + +function makeEntry(overrides: Partial = {}): RateLimitEntry { + return { + count: 1, + windowStart: Date.now(), + blocked: false, + blockedUntil: 0, + ...overrides, + }; +} + +// Tests + +describe('RateLimitStore', () => { + let store: RateLimitStore; + + beforeEach(() => { + // sweepIntervalMs=0 disables the background timer (Infinity avoids firing) + store = new RateLimitStore({ sweepIntervalMs: 9_999_999 }); + }); + + afterEach(() => { + store.destroy(); + }); + + + describe('hashKey', () => { + it('returns a 64-char hex string', () => { + const hash = RateLimitStore.hashKey('127.0.0.1'); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('is deterministic for the same input', () => { + expect(RateLimitStore.hashKey('192.168.1.1')).toBe( + RateLimitStore.hashKey('192.168.1.1'), + ); + }); + + it('produces different hashes for different inputs', () => { + expect(RateLimitStore.hashKey('1.1.1.1')).not.toBe( + RateLimitStore.hashKey('8.8.8.8'), + ); + }); + + it('does NOT expose the raw key in the hash', () => { + const hash = RateLimitStore.hashKey('192.168.0.1'); + expect(hash).not.toContain('192.168.0.1'); + }); + }); + + describe('get / set / delete', () => { + it('returns undefined for an unknown key', () => { + expect(store.get('unknown-key')).toBeUndefined(); + }); + + it('stores and retrieves an entry', () => { + const entry = makeEntry({ count: 5 }); + store.set('ip-a', entry); + expect(store.get('ip-a')).toEqual(entry); + }); + + it('increments size on set', () => { + store.set('ip-a', makeEntry()); + store.set('ip-b', makeEntry()); + expect(store.size).toBe(2); + }); + + it('overwrites an existing entry', () => { + store.set('ip-a', makeEntry({ count: 1 })); + store.set('ip-a', makeEntry({ count: 99 })); + expect(store.get('ip-a')?.count).toBe(99); + }); + + it('deletes an entry', () => { + store.set('ip-a', makeEntry()); + store.delete('ip-a'); + expect(store.get('ip-a')).toBeUndefined(); + expect(store.size).toBe(0); + }); + + it('delete on a missing key is a no-op', () => { + expect(() => store.delete('nonexistent')).not.toThrow(); + }); + }); + + describe('sweep', () => { + const WINDOW = 60_000; + + it('removes entries whose window has expired and are not blocked', () => { + const old = makeEntry({ windowStart: Date.now() - WINDOW - 1 }); + store.set('old-ip', old); + store.sweep(WINDOW); + expect(store.get('old-ip')).toBeUndefined(); + }); + + it('retains entries still within the active window', () => { + const fresh = makeEntry({ windowStart: Date.now() }); + store.set('fresh-ip', fresh); + store.sweep(WINDOW); + expect(store.get('fresh-ip')).toBeDefined(); + }); + + it('retains entries that are hard-blocked even if window expired', () => { + const blocked = makeEntry({ + windowStart: Date.now() - WINDOW - 1, + blocked: true, + blockedUntil: Date.now() + 60_000, + }); + store.set('blocked-ip', blocked); + store.sweep(WINDOW); + expect(store.get('blocked-ip')).toBeDefined(); + }); + + it('removes entries whose block has also expired', () => { + const expiredBlock = makeEntry({ + windowStart: Date.now() - WINDOW - 1, + blocked: true, + blockedUntil: Date.now() - 1, // already in the past + }); + store.set('expired-block-ip', expiredBlock); + store.sweep(WINDOW); + expect(store.get('expired-block-ip')).toBeUndefined(); + }); + + it('only removes qualifying entries, leaving others intact', () => { + const old = makeEntry({ windowStart: Date.now() - WINDOW - 1 }); + const fresh = makeEntry({ windowStart: Date.now() }); + store.set('old-ip', old); + store.set('fresh-ip', fresh); + store.sweep(WINDOW); + expect(store.get('old-ip')).toBeUndefined(); + expect(store.get('fresh-ip')).toBeDefined(); + }); + }); + + + describe('destroy', () => { + it('clears all entries', () => { + store.set('ip-a', makeEntry()); + store.set('ip-b', makeEntry()); + store.destroy(); + expect(store.size).toBe(0); + }); + + it('can be called multiple times without throwing', () => { + expect(() => { + store.destroy(); + store.destroy(); + }).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/src/middleware/__tests__/rateLimiter.test.ts b/src/middleware/__tests__/rateLimiter.test.ts new file mode 100644 index 0000000..4f12196 --- /dev/null +++ b/src/middleware/__tests__/rateLimiter.test.ts @@ -0,0 +1,318 @@ +/** + * @file rateLimiter.test.ts + * @description Unit and integration tests for the rate-limiter middleware. + * + * Strategy + * ──────── + * All tests drive a minimal Express app wired with `createRateLimiter`. + * We use `supertest` for HTTP-level assertions and `jest.useFakeTimers` + * where we need to control `Date.now()` to verify window rolling and + * block expiry without actually waiting. + * + * Coverage targets (≥ 95 %): + * - Requests within limit → 200, correct headers + * - Requests exceeding limit → 429, Retry-After header + * - Sliding-window reset after windowMs + * - Abuse guard triggers hard-block after abuseThreshold violations + * - Hard-block persists; 429 returned for all requests during block + * - Block expiry → requests allowed again + * - Exponential back-off doubles block duration on successive abuse + * - Custom keyFn is respected + * - /health is NOT limited (index integration) + * - Missing / unknown IP falls back to 'unknown' + * - X-Forwarded-For header extraction (first value) + * - sendHeaders=false suppresses rate-limit headers + * - Shared store enables coordination between two limiter instances + */ + +import express, { Request, Response } from 'express'; +import request from 'supertest'; +import { createRateLimiter } from '../rateLimiter'; +import { RateLimitStore } from '../../lib/rateLimitStore'; + + +/** Build a minimal test app with the given limiter mounted on /api/ */ +function buildApp(limiterOverrides: Parameters[0] = {}) { + const app = express(); + app.use(express.json()); + app.use('/api/', createRateLimiter(limiterOverrides)); + app.get('/api/test', (_req: Request, res: Response) => res.json({ ok: true })); + app.get('/health', (_req: Request, res: Response) => res.json({ status: 'ok' })); + return app; +} + +/** Fire `n` sequential requests against `path` from the same IP */ +async function fireRequests( + app: ReturnType, + n: number, + path = '/api/test', + ip = '1.2.3.4', +) { + const results: request.Response[] = []; + for (let i = 0; i < n; i++) { + results.push(await request(app).get(path).set('X-Forwarded-For', ip)); + } + return results; +} + + +describe('createRateLimiter – middleware', () => { + + describe('within rate limit', () => { + it('returns 200 for requests within the limit', async () => { + const app = buildApp({ maxRequests: 5, windowMs: 60_000 }); + const res = await request(app).get('/api/test').set('X-Forwarded-For', '1.1.1.1'); + expect(res.status).toBe(200); + }); + + it('sets X-RateLimit-Limit header', async () => { + const app = buildApp({ maxRequests: 10, windowMs: 60_000 }); + const res = await request(app).get('/api/test').set('X-Forwarded-For', '1.1.1.1'); + expect(res.headers['x-ratelimit-limit']).toBe('10'); + }); + + it('decrements X-RateLimit-Remaining with each request', async () => { + const app = buildApp({ maxRequests: 5, windowMs: 60_000 }); + const [r1, r2, r3] = await fireRequests(app, 3, '/api/test', '2.2.2.2'); + expect(Number(r1.headers['x-ratelimit-remaining'])).toBe(4); + expect(Number(r2.headers['x-ratelimit-remaining'])).toBe(3); + expect(Number(r3.headers['x-ratelimit-remaining'])).toBe(2); + }); + + it('sets X-RateLimit-Reset to a positive integer', async () => { + const app = buildApp({ maxRequests: 10, windowMs: 30_000 }); + const res = await request(app).get('/api/test').set('X-Forwarded-For', '3.3.3.3'); + expect(Number(res.headers['x-ratelimit-reset'])).toBeGreaterThan(0); + }); + }); + + describe('rate limit exceeded', () => { + it('returns 429 when limit is breached', async () => { + const app = buildApp({ maxRequests: 3, windowMs: 60_000, abuseThreshold: 99 }); + const results = await fireRequests(app, 4, '/api/test', '5.5.5.5'); + expect(results[3].status).toBe(429); + }); + + it('includes retryAfter in the 429 response body', async () => { + const app = buildApp({ maxRequests: 2, windowMs: 60_000, abuseThreshold: 99 }); + const results = await fireRequests(app, 3, '/api/test', '6.6.6.6'); + expect(results[2].body).toHaveProperty('retryAfter'); + expect(results[2].body.retryAfter).toBeGreaterThan(0); + }); + + it('sets Retry-After header on 429', async () => { + const app = buildApp({ maxRequests: 2, windowMs: 60_000, abuseThreshold: 99 }); + const results = await fireRequests(app, 3, '/api/test', '7.7.7.7'); + expect(results[2].headers['retry-after']).toBeDefined(); + }); + + it('returns error JSON with expected shape', async () => { + const app = buildApp({ maxRequests: 1, windowMs: 60_000, abuseThreshold: 99 }); + const results = await fireRequests(app, 2, '/api/test', '8.8.8.8'); + expect(results[1].body).toMatchObject({ error: 'Too Many Requests' }); + }); + }); + + // ── sliding window reset ──────────────────────────────────────────────── + + describe('sliding window', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('resets the counter after windowMs elapses', async () => { + const store = new RateLimitStore({ sweepIntervalMs: 9_999_999 }); + const app = buildApp({ maxRequests: 2, windowMs: 1_000, abuseThreshold: 99, store }); + + // Exhaust the limit + await fireRequests(app, 2, '/api/test', '9.9.9.9'); + + // Advance past the window + jest.advanceTimersByTime(1_001); + + // Should be allowed again + const res = await request(app).get('/api/test').set('X-Forwarded-For', '9.9.9.9'); + expect(res.status).toBe(200); + store.destroy(); + }); + }); + + describe('abuse guard', () => { + it('hard-blocks after abuseThreshold violations', async () => { + // maxRequests=1, abuseThreshold=2 → block on 2nd violation + const app = buildApp({ + maxRequests: 1, + windowMs: 60_000, + abuseThreshold: 2, + blockDurationMs: 60_000, + }); + const ip = '10.0.0.1'; + + // 1st violation (request 2 over limit of 1) + await fireRequests(app, 2, '/api/test', ip); + // 2nd violation = abuse threshold reached + const results = await fireRequests(app, 1, '/api/test', ip); + const blocked = results[0]; + + // May already be blocked or just rate-limited; subsequent should block + const followUp = await request(app).get('/api/test').set('X-Forwarded-For', ip); + expect([followUp.status]).toContain(429); + }); + + it('sets X-RateLimit-Blocked header when hard-blocked', async () => { + const app = buildApp({ + maxRequests: 1, + windowMs: 60_000, + abuseThreshold: 2, + blockDurationMs: 60_000, + }); + const ip = '10.0.0.2'; + // Trigger enough violations to hit the hard block + const results = await fireRequests(app, 5, '/api/test', ip); + const blockedRes = results.find(r => r.headers['x-ratelimit-blocked'] === 'true'); + expect(blockedRes).toBeDefined(); + }); + + it('blocked message differs from plain rate-limit message', async () => { + const app = buildApp({ + maxRequests: 1, + windowMs: 60_000, + abuseThreshold: 2, + blockDurationMs: 60_000, + }); + const ip = '10.0.0.3'; + const results = await fireRequests(app, 6, '/api/test', ip); + const blockedBody = results.find( + r => r.body?.message?.toLowerCase().includes('blocked'), + ); + expect(blockedBody).toBeDefined(); + }); + + it('block expires and allows traffic again', async () => { + jest.useFakeTimers(); + const store = new RateLimitStore({ sweepIntervalMs: 9_999_999 }); + const app = buildApp({ + maxRequests: 1, + windowMs: 500, + abuseThreshold: 2, + blockDurationMs: 1_000, + store, + }); + const ip = '10.0.0.4'; + + // Trigger hard-block + await fireRequests(app, 5, '/api/test', ip); + + // Advance past block duration + window + jest.advanceTimersByTime(2_000); + + const res = await request(app).get('/api/test').set('X-Forwarded-For', ip); + expect(res.status).toBe(200); + jest.useRealTimers(); + store.destroy(); + }); + }); + + describe('sendHeaders=false', () => { + it('does not send X-RateLimit-* headers', async () => { + const app = buildApp({ maxRequests: 10, windowMs: 60_000, sendHeaders: false }); + const res = await request(app).get('/api/test').set('X-Forwarded-For', '11.11.11.11'); + expect(res.headers['x-ratelimit-limit']).toBeUndefined(); + expect(res.headers['x-ratelimit-remaining']).toBeUndefined(); + }); + }); + + + describe('key extraction', () => { + it('isolates rate limits per IP', async () => { + const app = buildApp({ maxRequests: 2, windowMs: 60_000, abuseThreshold: 99 }); + // IP A exhausts its limit + await fireRequests(app, 3, '/api/test', '20.0.0.1'); + // IP B is unaffected + const res = await request(app).get('/api/test').set('X-Forwarded-For', '20.0.0.2'); + expect(res.status).toBe(200); + }); + + it('uses first IP from X-Forwarded-For', async () => { + const app = buildApp({ maxRequests: 2, windowMs: 60_000, abuseThreshold: 99 }); + // Same leading IP, different trailing proxy IPs → same bucket + for (let i = 0; i < 2; i++) { + await request(app).get('/api/test').set('X-Forwarded-For', `30.0.0.1, 99.99.99.${i}`); + } + const res = await request(app) + .get('/api/test') + .set('X-Forwarded-For', '30.0.0.1, 99.99.99.9'); + expect(res.status).toBe(429); + }); + + it('custom keyFn is used as the bucket key', async () => { + const app = buildApp({ + maxRequests: 1, + windowMs: 60_000, + abuseThreshold: 99, + // Bucket by a fixed key → all requests share the same counter + keyFn: () => 'global', + }); + await request(app).get('/api/test').set('X-Forwarded-For', '40.0.0.1'); + const res = await request(app).get('/api/test').set('X-Forwarded-For', '40.0.0.2'); + expect(res.status).toBe(429); + }); + }); + + + describe('shared store', () => { + it('two limiter instances sharing a store see the same counter', async () => { + const store = new RateLimitStore({ sweepIntervalMs: 9_999_999 }); + const cfg = { maxRequests: 2, windowMs: 60_000, abuseThreshold: 99, store }; + + const appA = buildApp(cfg); + const appB = buildApp(cfg); + + await request(appA).get('/api/test').set('X-Forwarded-For', '50.0.0.1'); + await request(appB).get('/api/test').set('X-Forwarded-For', '50.0.0.1'); + + // Third request should be blocked (limit=2 already consumed across both apps) + const res = await request(appA).get('/api/test').set('X-Forwarded-For', '50.0.0.1'); + expect(res.status).toBe(429); + store.destroy(); + }); + }); + + describe('health endpoint', () => { + it('/health is not rate-limited (no X-RateLimit headers)', async () => { + const app = buildApp({ maxRequests: 1, windowMs: 60_000 }); + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.headers['x-ratelimit-limit']).toBeUndefined(); + }); + }); +}); + +// let app: express.Express; + +// beforeAll(() => { +// // Import via dynamic require so we can set env vars before import +// process.env.RATE_LIMIT_MAX_REQUESTS = '3'; +// process.env.RATE_LIMIT_WINDOW_MS = '60000'; +// // eslint-disable-next-line @typescript-eslint/no-var-requires +// app = require('../../index').app; +// }); + +// it('GET /health returns 200', async () => { +// const res = await request(app).get('/health'); +// expect(res.status).toBe(200); +// expect(res.body).toMatchObject({ status: 'ok' }); +// }); + +// it('GET /api/v1/contracts returns 200 within limit', async () => { +// const res = await request(app).get('/api/v1/contracts').set('X-Forwarded-For', '60.0.0.1'); +// expect(res.status).toBe(200); +// expect(res.body).toHaveProperty('contracts'); +// }); + +// it('GET /api/v1/contracts returns 429 after limit', async () => { +// const ip = '60.0.0.99'; +// await fireRequests(app, 3, '/api/v1/contracts', ip); +// const res = await request(app).get('/api/v1/contracts').set('X-Forwarded-For', ip); +// expect(res.status).toBe(429); +// }); +// }); \ No newline at end of file diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts new file mode 100644 index 0000000..aa43828 --- /dev/null +++ b/src/middleware/rateLimiter.ts @@ -0,0 +1,262 @@ +/** + * @module rateLimiter + * @description + * Adaptive rate-limiting and abuse-guard middleware for Express. + * + * ## Algorithm + * Uses a **sliding-window counter** per key (default: client IP). + * When a key exceeds `maxRequests` within `windowMs`: + * 1. A 429 response is returned immediately. + * 2. The abuse guard checks whether the violation count itself exceeds + * `abuseThreshold`. If so, the key is **hard-blocked** for `blockDurationMs`. + * + * ## Adaptive throttling + * The abuse guard doubles the block duration on every successive violation + * (exponential back-off), up to `maxBlockDurationMs`. + * + * ## Key extraction + * By default the middleware uses `X-Forwarded-For` (trusting one proxy hop) + * then falls back to `req.ip`. Callers can supply a custom `keyFn` for + * API-key-scoped or user-scoped limiting. + * + * @security + * - Keys are hashed in the store — raw IPs are never persisted. + * - All timing operations use `Date.now()` (monotonic in V8 ≥ Node 16). + * - Blocked responses include `Retry-After` to aid legitimate clients. + * - Headers expose only aggregate counts, never raw keys. + * + * @example + * ```ts + * import { createRateLimiter } from './middleware/rateLimiter'; + * + * const limiter = createRateLimiter({ maxRequests: 100, windowMs: 60_000 }); + * app.use('/api/', limiter); + * ``` + */ + +import { Request, Response, NextFunction } from 'express'; +import { RateLimitStore } from '../lib/rateLimitStore'; + + +export interface RateLimiterConfig { + /** + * Maximum requests allowed per `windowMs`. + * @default 100 + */ + maxRequests?: number; + + /** + * Duration (ms) of the sliding window. + * @default 60_000 (1 minute) + */ + windowMs?: number; + + /** + * Number of rate-limit violations within `blockWindowMs` before + * the key is hard-blocked. + * @default 5 + */ + abuseThreshold?: number; + + /** + * How long (ms) to observe violations for the abuse threshold. + * @default 300_000 (5 minutes) + */ + blockWindowMs?: number; + + /** + * Initial block duration (ms) applied when the abuse threshold is hit. + * @default 600_000 (10 minutes) + */ + blockDurationMs?: number; + + /** + * Maximum block duration (ms) after exponential back-off. + * @default 86_400_000 (24 hours) + */ + maxBlockDurationMs?: number; + + /** + * Custom function to derive the rate-limit key from a request. + * Defaults to IP-based extraction. + */ + keyFn?: (req: Request) => string; + + /** + * If true, rate-limit headers are added to every response. + * @default true + */ + sendHeaders?: boolean; + + /** + * Shared store instance. Useful for testing or multi-limiter coordination. + * A new store is created if omitted. + */ + store?: RateLimitStore; +} + +// ─── Internal state per key ─────────────────────────────────────────────────── + +interface AbuseRecord { + violations: number; + firstViolation: number; + blockDuration: number; // current (possibly doubled) block duration +} + +// ─── Factory ────────────────────────────────────────────────────────────────── + +/** + * Create an Express middleware that enforces rate limiting and abuse guards. + * + * @param config - {@link RateLimiterConfig} options + * @returns Express middleware function + */ +export function createRateLimiter(config: RateLimiterConfig = {}) { + const { + maxRequests = 100, + windowMs = 60_000, + abuseThreshold = 5, + blockWindowMs = 300_000, + blockDurationMs = 600_000, + maxBlockDurationMs = 86_400_000, + keyFn = defaultKeyFn, + sendHeaders = true, + store = new RateLimitStore({ sweepIntervalMs: windowMs }), + } = config; + + // Secondary map tracks violation/block metadata (not persisted in the store) + const abuseMap = new Map(); + + // ─── Middleware ────────────────────────────────────────────────────────── + + return function rateLimiterMiddleware( + req: Request, + res: Response, + next: NextFunction, + ): void { + const rawKey = keyFn(req); + const now = Date.now(); + + // ── 1. Check hard-block ────────────────────────────────────────────── + const existing = store.get(rawKey); + if (existing?.blocked) { + if (now < existing.blockedUntil) { + const retryAfterSec = Math.ceil((existing.blockedUntil - now) / 1000); + if (sendHeaders) { + res.setHeader('Retry-After', retryAfterSec); + res.setHeader('X-RateLimit-Blocked', 'true'); + } + res.status(429).json({ + error: 'Too Many Requests', + message: 'Your access has been temporarily blocked due to excessive requests.', + retryAfter: retryAfterSec, + }); + return; + } + // Block expired — reset + store.delete(rawKey); + abuseMap.delete(RateLimitStore.hashKey(rawKey)); + } + + // ── 2. Sliding-window counter ──────────────────────────────────────── + const entry = store.get(rawKey) ?? { + count: 0, + windowStart: now, + blocked: false, + blockedUntil: 0, + }; + + // Roll the window if it has elapsed + if (now - entry.windowStart > windowMs) { + entry.count = 0; + entry.windowStart = now; + } + + entry.count += 1; + store.set(rawKey, entry); + + const remaining = Math.max(0, maxRequests - entry.count); + const resetSec = Math.ceil((entry.windowStart + windowMs - now) / 1000); + + if (sendHeaders) { + res.setHeader('X-RateLimit-Limit', maxRequests); + res.setHeader('X-RateLimit-Remaining', remaining); + res.setHeader('X-RateLimit-Reset', resetSec); + } + + // ── 3. Limit exceeded → abuse guard evaluation ─────────────────────── + if (entry.count > maxRequests) { + const hashedKey = RateLimitStore.hashKey(rawKey); + const abuse = abuseMap.get(hashedKey) ?? { + violations: 0, + firstViolation: now, + blockDuration: blockDurationMs, + }; + + // Reset violation window if older than blockWindowMs + if (now - abuse.firstViolation > blockWindowMs) { + abuse.violations = 0; + abuse.firstViolation = now; + abuse.blockDuration = blockDurationMs; + } + + abuse.violations += 1; + + if (abuse.violations >= abuseThreshold) { + // Exponential back-off on repeated abuse + const duration = Math.min(abuse.blockDuration, maxBlockDurationMs); + abuse.blockDuration = Math.min(abuse.blockDuration * 2, maxBlockDurationMs); + + entry.blocked = true; + entry.blockedUntil = now + duration; + store.set(rawKey, entry); + abuseMap.set(hashedKey, abuse); + + const retryAfterSec = Math.ceil(duration / 1000); + if (sendHeaders) { + res.setHeader('Retry-After', retryAfterSec); + res.setHeader('X-RateLimit-Blocked', 'true'); + } + res.status(429).json({ + error: 'Too Many Requests', + message: 'Abuse detected. Your access has been temporarily blocked.', + retryAfter: retryAfterSec, + }); + return; + } + + abuseMap.set(hashedKey, abuse); + + if (sendHeaders) res.setHeader('Retry-After', resetSec); + res.status(429).json({ + error: 'Too Many Requests', + message: `Rate limit exceeded. Try again in ${resetSec} second(s).`, + retryAfter: resetSec, + }); + return; + } + + next(); + }; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Extract the client key (IP) from a request. + * Prefers the first value of X-Forwarded-For (one trusted proxy hop), + * then falls back to `req.ip`, then `req.socket.remoteAddress`. + * + * @security + * In production, set `app.set('trust proxy', 1)` if behind a single + * reverse-proxy so Express normalises `req.ip` correctly, and set + * `keyFn` to use `req.ip` only (XFF is easily spoofed otherwise). + */ +function defaultKeyFn(req: Request): string { + const xff = req.headers['x-forwarded-for']; + if (xff) { + const first = Array.isArray(xff) ? xff[0] : xff.split(',')[0]; + return first.trim(); + } + return req.ip ?? req.socket?.remoteAddress ?? 'unknown'; +} \ No newline at end of file From ea5460b9541df3aaa03e23cb4991af61c30dbd84 Mon Sep 17 00:00:00 2001 From: blessme247 Date: Wed, 25 Mar 2026 11:16:59 +0100 Subject: [PATCH 4/4] chore: cleanup comment in index file --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index a8843f2..ab7b232 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ const PORT = process.env.PORT || 3001; app.use(express.json()); -// Rate limiting +// Rate limiting // Applied only to /api/* routes to leave /health unthrottled for load-balancer // probes and monitoring.