test: add OAuth login route test suite (22 cases)
Guards the browser OAuth popup flow used by claude.ai and ChatGPT: - GET /login: return_to URL validation, XSS escaping, error display - POST /login: first-party cookie properties (httpOnly/secure/lax/domain), open redirect blocking, credential rejection paths - GET /oauth/authorize: must redirect to /login (never app.squaremcp.com), return_to encoding, valid session bypasses redirect Also exports `app` from index.ts and guards main() with NODE_ENV !== 'test' so the Express app can be imported by supertest without triggering DB init. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
286
package-lock.json
generated
286
package-lock.json
generated
@@ -28,10 +28,12 @@
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"playwright": "^1.59.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.6"
|
||||
@@ -957,6 +959,19 @@
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"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/@oxc-project/types": {
|
||||
"version": "0.129.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
|
||||
@@ -967,6 +982,16 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"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/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
@@ -1376,6 +1401,13 @@
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"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/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
@@ -1443,6 +1475,13 @@
|
||||
"@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",
|
||||
@@ -1523,6 +1562,30 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"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/@vitest/coverage-v8": {
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
|
||||
@@ -1730,6 +1793,13 @@
|
||||
"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/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -1752,6 +1822,13 @@
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"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/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -1881,6 +1958,29 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"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/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -1943,6 +2043,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/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
@@ -1983,6 +2090,16 @@
|
||||
"ms": "2.0.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/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
@@ -2021,6 +2138,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/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -2117,6 +2245,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/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
@@ -2285,6 +2429,13 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"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/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
@@ -2337,6 +2488,41 @@
|
||||
"node": ">= 0.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",
|
||||
@@ -2472,6 +2658,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",
|
||||
@@ -4055,6 +4257,90 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -38,10 +38,12 @@
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"playwright": "^1.59.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.6"
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@@ -2134,7 +2134,11 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
export { app };
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
main().catch((err) => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
420
src/oauth-login-routes.test.ts
Normal file
420
src/oauth-login-routes.test.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Tests for the OAuth login flow routes and the /oauth/authorize redirect behavior.
|
||||
*
|
||||
* Critical invariants guarded here:
|
||||
* 1. GET /login serves an HTML form and validates the return_to URL
|
||||
* 2. POST /login sets a first-party session cookie with correct security properties
|
||||
* 3. POST /login with bad credentials redirects to /login?error=invalid (no open redirect)
|
||||
* 4. GET /oauth/authorize unauthenticated → redirects to /login (NOT app.squaremcp.com)
|
||||
* 5. GET /oauth/authorize with valid session → shows consent page
|
||||
*
|
||||
* Why these matter: claude.ai and ChatGPT use browser-based OAuth popups. If the
|
||||
* authorize endpoint redirects to app.squaremcp.com instead of hermes /login, the
|
||||
* session cookie is set in the wrong top-level context (browser CHIPS partitioning)
|
||||
* and the OAuth flow fails silently.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
|
||||
// ── Hoist mocks before any imports ────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
mockFindCustomerByEmail,
|
||||
mockVerifyPassword,
|
||||
mockSignJWT,
|
||||
mockVerifyJWT,
|
||||
mockGetClient,
|
||||
mockIsValidRedirectUri,
|
||||
mockGetAuthorizeHtml,
|
||||
mockInitDatabase,
|
||||
mockGetPool,
|
||||
mockRedis,
|
||||
mockEnsureOAuthAppRegistered,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFindCustomerByEmail: vi.fn(),
|
||||
mockVerifyPassword: vi.fn(),
|
||||
mockSignJWT: vi.fn(),
|
||||
mockVerifyJWT: vi.fn(),
|
||||
mockGetClient: vi.fn(),
|
||||
mockIsValidRedirectUri: vi.fn(),
|
||||
mockGetAuthorizeHtml: vi.fn(),
|
||||
mockInitDatabase: vi.fn(),
|
||||
mockGetPool: vi.fn(() => ({ execute: vi.fn(), query: vi.fn() })),
|
||||
mockRedis: { get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||
mockEnsureOAuthAppRegistered: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./auth.js', () => ({
|
||||
findCustomerByEmail: mockFindCustomerByEmail,
|
||||
findCustomerById: vi.fn(),
|
||||
verifyPassword: mockVerifyPassword,
|
||||
signJWT: mockSignJWT,
|
||||
verifyJWT: mockVerifyJWT,
|
||||
hashPassword: vi.fn(),
|
||||
createCustomer: vi.fn(),
|
||||
setResetToken: vi.fn(),
|
||||
findCustomerByResetToken: vi.fn(),
|
||||
clearResetToken: vi.fn(),
|
||||
updatePassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./oauth.js', () => ({
|
||||
getClient: mockGetClient,
|
||||
isValidRedirectUri: mockIsValidRedirectUri,
|
||||
getAuthorizeHtml: mockGetAuthorizeHtml,
|
||||
registerClient: vi.fn(),
|
||||
createAuthCode: vi.fn(),
|
||||
exchangeCodeForToken: vi.fn(),
|
||||
validateAccessToken: vi.fn(),
|
||||
getTokenCustomer: vi.fn(),
|
||||
ensureOAuthAppRegistered: mockEnsureOAuthAppRegistered,
|
||||
revokeToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./db.js', () => ({
|
||||
initDatabase: mockInitDatabase,
|
||||
getPool: mockGetPool,
|
||||
isPoolReady: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('./redis.js', () => ({ default: mockRedis }));
|
||||
|
||||
vi.mock('./billing/middleware.js', () => ({
|
||||
meterMiddleware: vi.fn((_req: unknown, _res: unknown, next: () => void) => next()),
|
||||
resolveCustomerByApiKey: vi.fn(),
|
||||
resolveCustomerById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./billing/usage.js', () => ({
|
||||
recordUsage: vi.fn(),
|
||||
getMonthlyUsage: vi.fn(() => 0),
|
||||
getUsageBreakdown: vi.fn(() => []),
|
||||
checkLimit: vi.fn(() => ({ allowed: true, limit: 1000 })),
|
||||
}));
|
||||
|
||||
vi.mock('./billing/invoices.js', () => ({
|
||||
getCustomerInvoices: vi.fn(() => []),
|
||||
getInvoiceByNumber: vi.fn(),
|
||||
markInvoiceSent: vi.fn(),
|
||||
markInvoicePaid: vi.fn(),
|
||||
generateMonthlyInvoice: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./billing/cron.js', () => ({ startInvoiceCron: vi.fn() }));
|
||||
|
||||
vi.mock('./multitenancy/webhook-router.js', () => ({
|
||||
routeWhatsAppWebhook: vi.fn(),
|
||||
registerWhatsAppNumber: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./multitenancy/credential-store.js', () => ({
|
||||
storeCredential: vi.fn(),
|
||||
getCredential: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./multitenancy/platform-health.js', () => ({
|
||||
getAllPlatformHealth: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock('./multitenancy/audit-log.js', () => ({ logAudit: vi.fn() }));
|
||||
|
||||
vi.mock('./webhooks/delivery.js', () => ({
|
||||
deliverWebhook: vi.fn(),
|
||||
isValidWebhookUrl: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('./tools.js', () => ({
|
||||
tools: [],
|
||||
handleToolCall: vi.fn(),
|
||||
stripAccountParam: vi.fn((args: unknown) => args),
|
||||
}));
|
||||
|
||||
vi.mock('./manifest.js', () => ({
|
||||
getManifest: vi.fn(() => ({})),
|
||||
getOpenApiSpec: vi.fn(() => ({})),
|
||||
getOpenApiSpecMail: vi.fn(() => ({})),
|
||||
getOpenApiSpecSocial: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
// ── Import app after mocks ─────────────────────────────────────────────────────
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.SERVER_URL = 'https://hermes.squaremcp.com';
|
||||
process.env.MCP_API_KEY = 'test-global-key';
|
||||
|
||||
const { app } = await import('./index.js');
|
||||
|
||||
// ── Shared fixtures ────────────────────────────────────────────────────────────
|
||||
|
||||
const HERMES_RETURN_TO = 'https://hermes.squaremcp.com/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&state=xyz';
|
||||
|
||||
const fakeCustomer = {
|
||||
id: 'cust-1',
|
||||
email: 'user@example.com',
|
||||
plan: 'starter',
|
||||
role: 'user',
|
||||
active: true,
|
||||
api_key: 'api-key-1',
|
||||
password_hash: '$2b$10$hashedpassword',
|
||||
};
|
||||
|
||||
const fakeClient = {
|
||||
client_id: 'abc',
|
||||
client_secret: 'secret',
|
||||
client_name: 'Test Client',
|
||||
redirect_uris: ['https://claude.ai/api/mcp/auth_callback'],
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
// ── GET /login ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /login', () => {
|
||||
it('returns 200 HTML with a login form', async () => {
|
||||
const res = await request(app).get('/login');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/text\/html/);
|
||||
expect(res.text).toContain('<form method="POST" action="/login">');
|
||||
expect(res.text).toContain('SquareMCP');
|
||||
});
|
||||
|
||||
it('embeds a valid hermes return_to in the form hidden field', async () => {
|
||||
const res = await request(app).get(`/login?return_to=${encodeURIComponent(HERMES_RETURN_TO)}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('https://hermes.squaremcp.com/oauth/authorize');
|
||||
});
|
||||
|
||||
it('ignores return_to pointing to other domains (uses /)', async () => {
|
||||
const res = await request(app).get('/login?return_to=https%3A%2F%2Fevil.com%2Fsteal');
|
||||
expect(res.status).toBe(200);
|
||||
// Should fall back to / — the malicious URL must NOT appear in the form
|
||||
expect(res.text).not.toContain('evil.com');
|
||||
expect(res.text).toContain('value="/"');
|
||||
});
|
||||
|
||||
it('ignores return_to pointing to app.squaremcp.com (different subdomain)', async () => {
|
||||
const res = await request(app).get('/login?return_to=https%3A%2F%2Fapp.squaremcp.com%2Fdashboard');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).not.toContain('app.squaremcp.com');
|
||||
expect(res.text).toContain('value="/"');
|
||||
});
|
||||
|
||||
it('shows error message for error=invalid', async () => {
|
||||
const res = await request(app).get('/login?error=invalid');
|
||||
expect(res.text).toContain('Incorrect email or password');
|
||||
});
|
||||
|
||||
it('shows error message for error=missing', async () => {
|
||||
const res = await request(app).get('/login?error=missing');
|
||||
expect(res.text).toContain('Email and password are required');
|
||||
});
|
||||
|
||||
it('HTML-escapes double quotes in return_to to prevent attribute injection', async () => {
|
||||
const xssAttempt = 'https://hermes.squaremcp.com/oauth/authorize?x="onmouseover=alert(1)"';
|
||||
const res = await request(app).get(`/login?return_to=${encodeURIComponent(xssAttempt)}`);
|
||||
// Quotes should be replaced with " in the hidden field value
|
||||
expect(res.text).not.toContain('"onmouseover=alert(1)"');
|
||||
expect(res.text).toContain('"');
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /login ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /login', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSignJWT.mockReturnValue('jwt-token-abc');
|
||||
});
|
||||
|
||||
it('valid credentials → sets session cookie with correct security properties', async () => {
|
||||
mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
|
||||
mockVerifyPassword.mockResolvedValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/login')
|
||||
.type('form')
|
||||
.send({ email: 'user@example.com', password: 'correct', return_to: HERMES_RETURN_TO });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
|
||||
const setCookie = res.headers['set-cookie'] as string[] | string;
|
||||
const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : setCookie;
|
||||
|
||||
expect(cookieStr).toContain('session=jwt-token-abc');
|
||||
expect(cookieStr.toLowerCase()).toContain('httponly');
|
||||
expect(cookieStr.toLowerCase()).toContain('secure');
|
||||
expect(cookieStr.toLowerCase()).toContain('samesite=lax');
|
||||
expect(cookieStr.toLowerCase()).toContain('domain=.squaremcp.com');
|
||||
});
|
||||
|
||||
it('valid credentials → redirects to return_to on hermes domain', async () => {
|
||||
mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
|
||||
mockVerifyPassword.mockResolvedValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/login')
|
||||
.type('form')
|
||||
.send({ email: 'user@example.com', password: 'correct', return_to: HERMES_RETURN_TO });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe(HERMES_RETURN_TO);
|
||||
});
|
||||
|
||||
it('valid credentials + non-hermes return_to → redirects to / (open redirect blocked)', async () => {
|
||||
mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
|
||||
mockVerifyPassword.mockResolvedValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/login')
|
||||
.type('form')
|
||||
.send({ email: 'user@example.com', password: 'correct', return_to: 'https://evil.com/steal' });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe('/');
|
||||
expect(res.headers.location).not.toContain('evil.com');
|
||||
});
|
||||
|
||||
it('valid credentials + app.squaremcp.com return_to → redirects to / (wrong domain blocked)', async () => {
|
||||
mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
|
||||
mockVerifyPassword.mockResolvedValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/login')
|
||||
.type('form')
|
||||
.send({ email: 'user@example.com', password: 'correct', return_to: 'https://app.squaremcp.com/dashboard' });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe('/');
|
||||
});
|
||||
|
||||
it('wrong password → redirect to /login?error=invalid (no cookie set)', async () => {
|
||||
mockFindCustomerByEmail.mockResolvedValue(fakeCustomer);
|
||||
mockVerifyPassword.mockResolvedValue(false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/login')
|
||||
.type('form')
|
||||
.send({ email: 'user@example.com', password: 'wrong', return_to: HERMES_RETURN_TO });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toMatch(/\/login.*error=invalid/);
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('unknown email → redirect to /login?error=invalid', async () => {
|
||||
mockFindCustomerByEmail.mockResolvedValue(null);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/login')
|
||||
.type('form')
|
||||
.send({ email: 'nobody@example.com', password: 'anything', return_to: HERMES_RETURN_TO });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toMatch(/\/login.*error=invalid/);
|
||||
});
|
||||
|
||||
it('missing fields → redirect to /login?error=missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/login')
|
||||
.type('form')
|
||||
.send({ return_to: HERMES_RETURN_TO });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toMatch(/\/login.*error=missing/);
|
||||
});
|
||||
|
||||
it('preserves hermes return_to through failed login redirect', async () => {
|
||||
mockFindCustomerByEmail.mockResolvedValue(null);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/login')
|
||||
.type('form')
|
||||
.send({ email: 'x@x.com', password: 'bad', return_to: HERMES_RETURN_TO });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain(encodeURIComponent(HERMES_RETURN_TO));
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /oauth/authorize — redirect behavior ───────────────────────────────────
|
||||
|
||||
describe('GET /oauth/authorize — unauthenticated redirect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetClient.mockResolvedValue(fakeClient);
|
||||
mockIsValidRedirectUri.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('redirects to /login — NOT to app.squaremcp.com', async () => {
|
||||
const res = await request(app).get(
|
||||
'/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&response_type=code&state=xyz'
|
||||
);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
// Must redirect to /login (first-party on hermes)
|
||||
expect(res.headers.location).toMatch(/^\/login\?/);
|
||||
// Must NOT redirect to app.squaremcp.com (wrong cookie context — breaks browser OAuth popup)
|
||||
expect(res.headers.location).not.toContain('app.squaremcp.com');
|
||||
});
|
||||
|
||||
it('return_to in redirect encodes the full hermes.squaremcp.com authorize URL', async () => {
|
||||
const res = await request(app).get(
|
||||
'/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&state=xyz'
|
||||
);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
const location = res.headers.location as string;
|
||||
expect(location).toContain('return_to=');
|
||||
|
||||
// The encoded return_to must start with the hermes domain
|
||||
const returnToEncoded = new URL('http://dummy' + location).searchParams.get('return_to');
|
||||
expect(returnToEncoded).toMatch(/^https:\/\/hermes\.squaremcp\.com\//);
|
||||
expect(returnToEncoded).toContain('client_id=abc');
|
||||
});
|
||||
|
||||
it('with invalid cookie → also redirects to /login (not app.squaremcp.com)', async () => {
|
||||
mockVerifyJWT.mockImplementation(() => { throw new Error('invalid token'); });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback')
|
||||
.set('Cookie', 'session=bad-token');
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toMatch(/^\/login\?/);
|
||||
expect(res.headers.location).not.toContain('app.squaremcp.com');
|
||||
});
|
||||
|
||||
it('with valid session cookie → returns 200 consent HTML (no redirect)', async () => {
|
||||
mockVerifyJWT.mockReturnValue({ sub: 'cust-1', email: 'user@example.com', plan: 'starter' });
|
||||
mockGetAuthorizeHtml.mockReturnValue('<html>consent</html>');
|
||||
|
||||
const res = await request(app)
|
||||
.get('/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback')
|
||||
.set('Cookie', 'session=valid-jwt');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('consent');
|
||||
expect(res.headers.location).toBeUndefined();
|
||||
});
|
||||
|
||||
it('missing client_id → 400 (not a redirect)', async () => {
|
||||
const res = await request(app).get('/oauth/authorize?redirect_uri=https%3A%2F%2Fclaude.ai%2Fcallback');
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('unregistered client_id → 400', async () => {
|
||||
mockGetClient.mockResolvedValue(null);
|
||||
const res = await request(app).get(
|
||||
'/oauth/authorize?client_id=unknown&redirect_uri=https%3A%2F%2Fclaude.ai%2Fcallback'
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('redirect_uri not registered for client → 400', async () => {
|
||||
mockIsValidRedirectUri.mockReturnValue(false);
|
||||
const res = await request(app).get(
|
||||
'/oauth/authorize?client_id=abc&redirect_uri=https%3A%2F%2Fevil.com%2Fcallback'
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user