diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 26166d3..7c7f801 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -57,3 +57,6 @@ jobs: run: | pnpm run build pnpm tsx scripts/post-build.ts + + - name: Test + run: pnpm test diff --git a/README.md b/README.md index 22e3b57..627c7d9 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ zenstack-proxy [options] - `-p, --port ` Port number for the server (default: `8008`) - `-s, --schema ` - Path to ZModel schema file (default: "schema.zmodel") - `-d, --datasource-url ` Datasource URL (overrides schema configuration) +- `--studioAuthKey ` Authentication key from ZenStack Studio. When set, the proxy will only accept requests signed by your Studio project. - `-l, --log ` Query log levels (e.g., query, info, warn, error) +- `--signature-tolerance-secs ` Time tolerance in seconds for signed requests (default: `60`) ### Examples @@ -46,6 +48,20 @@ zenstack-proxy -s ./schema/schema.zmodel -z ./generated/zenstack zenstack-proxy -p 8888 ``` +#### Enable signed requests + +```bash +zenstack-proxy --studioAuthKey "MCowBQYDK2VwAyEAFSJV7wjdFuDz2CqYX7hGnITQvcmJYy7OJQq2Cy2Eiqs=" +``` + +When `--studioAuthKey` is provided, every incoming request must include an `X-ZenStack-Signature` header in the format `t=,v1=`. +The signed message format matches ZenStack Studio: `payload + timestamp [+ authorizationToken]`. + +- For `GET` and `DELETE` requests, `payload` is the raw query string without the leading `?`. +- For body-based requests, `payload` is the exact JSON request body string. +- For requests without query params or a request body, `payload` is an empty string. +- If an `Authorization: Bearer ` header is present, append `` to the signed message. + ## Server Endpoints The server provides the following endpoints: diff --git a/package.json b/package.json index 99b0e13..d8b38b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/proxy", - "version": "0.4.2", + "version": "0.5.0", "description": "A CLI tool to run an Express server that proxies CRUD requests to a ZenStack backend", "main": "index.js", "publishConfig": { @@ -18,7 +18,8 @@ }, "scripts": { "clean": "rimraf dist", - "build": "pnpm clean && tsc && copyfiles -F \"bin/*\" dist && copyfiles ./README.md ./LICENSE ./package.json dist && pnpm pack dist --pack-destination ../build" + "build": "pnpm clean && tsc && copyfiles -F \"bin/*\" dist && copyfiles ./README.md ./LICENSE ./package.json dist && pnpm pack dist --pack-destination ../build", + "test": "node --require tsx/cjs --test test/*.test.ts " }, "keywords": [ "zenstack", @@ -49,8 +50,10 @@ "@types/express": "^5.0.0", "@types/node": "^20.0.0", "@types/semver": "^7.7.1", + "@types/supertest": "^6.0.2", "copyfiles": "^2.4.1", "rimraf": "^4.0.0", + "supertest": "^7.0.0", "typescript": "^5.0.0" }, "exports": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2374ff5..38261d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,12 +63,18 @@ importers: '@types/semver': specifier: ^7.7.1 version: 7.7.1 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 copyfiles: specifier: ^2.4.1 version: 2.4.1 rimraf: specifier: ^4.0.0 version: 4.4.1 + supertest: + specifier: ^7.0.0 + version: 7.2.2 typescript: specifier: ^5.0.0 version: 5.9.3 @@ -232,6 +238,13 @@ packages: cpu: [x64] os: [win32] + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@prisma/adapter-better-sqlite3@6.19.2': resolution: {integrity: sha512-SCDyUS30NlHjgfghEfech1GYScxlDzedFBgNrlQk1bb9N/vGLwvtDwsMqaHlhusnrm2w1eMllTThBZ5vlIsEOQ==} @@ -274,6 +287,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} @@ -289,6 +305,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/node@20.19.27': resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} @@ -310,6 +329,12 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/superagent@8.1.10': + resolution: {integrity: sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@zenstackhq/runtime@2.22.2': resolution: {integrity: sha512-GgWSyU1nL1q89ZOS7O7cn9GWUcQ2ywavTp73RJvqRw0HSQ2r5sCi+wAyh2eAxqGTSnBob4jg2kq7CcFqcaze9A==} peerDependencies: @@ -340,6 +365,12 @@ packages: array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -403,10 +434,17 @@ packages: resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} engines: {node: '>=0.1.90'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@12.1.0: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -421,10 +459,17 @@ packages: cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + copy-anything@3.0.5: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} engines: {node: '>=12.13'} @@ -479,6 +524,10 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -495,6 +544,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dotenv@17.2.3: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} @@ -528,6 +580,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -552,6 +608,9 @@ packages: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -559,6 +618,14 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -615,6 +682,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -708,6 +779,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -983,6 +1059,10 @@ packages: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + superjson@1.13.3: resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==} engines: {node: '>=10'} @@ -991,6 +1071,10 @@ packages: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -1184,6 +1268,12 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@prisma/adapter-better-sqlite3@6.19.2': dependencies: '@prisma/driver-adapter-utils': 6.19.2 @@ -1231,6 +1321,8 @@ snapshots: dependencies: '@types/node': 20.19.27 + '@types/cookiejar@2.1.5': {} + '@types/cors@2.8.19': dependencies: '@types/node': 20.19.27 @@ -1252,6 +1344,8 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/methods@1.1.4': {} + '@types/node@20.19.27': dependencies: undici-types: 6.21.0 @@ -1275,6 +1369,18 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 20.19.27 + '@types/superagent@8.1.10': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 20.19.27 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.10 + '@zenstackhq/runtime@2.22.2(@prisma/client@7.2.0(typescript@5.9.3))(zod@4.3.5)': dependencies: '@prisma/client': 7.2.0(typescript@5.9.3) @@ -1319,6 +1425,10 @@ snapshots: array-flatten@1.1.1: {} + asap@2.0.6: {} + + asynckit@0.4.0: {} + balanced-match@1.0.2: {} base64-js@1.5.1: {} @@ -1404,8 +1514,14 @@ snapshots: colors@1.4.0: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@12.1.0: {} + component-emitter@1.3.1: {} + concat-map@0.0.1: {} content-disposition@0.5.4: @@ -1416,8 +1532,12 @@ snapshots: cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} + cookie@0.7.2: {} + cookiejar@2.1.4: {} + copy-anything@3.0.5: dependencies: is-what: 4.1.16 @@ -1463,6 +1583,8 @@ snapshots: deepmerge@4.3.1: {} + delayed-stream@1.0.0: {} + denque@2.1.0: {} depd@2.0.0: {} @@ -1471,6 +1593,11 @@ snapshots: detect-libc@2.1.2: {} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + dotenv@17.2.3: {} dunder-proto@1.0.1: @@ -1497,6 +1624,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -1570,6 +1704,8 @@ snapshots: transitivePeerDependencies: - supports-color + fast-safe-stringify@2.1.1: {} + file-uri-to-path@1.0.0: {} finalhandler@1.3.2: @@ -1584,6 +1720,20 @@ snapshots: transitivePeerDependencies: - supports-color + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -1643,6 +1793,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -1723,6 +1877,8 @@ snapshots: mime@1.6.0: {} + mime@2.6.0: {} + mimic-response@3.1.0: {} minimatch@3.1.2: @@ -2017,6 +2173,20 @@ snapshots: strip-json-comments@2.0.1: {} + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + 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 + transitivePeerDependencies: + - supports-color + superjson@1.13.3: dependencies: copy-anything: 3.0.5 @@ -2025,6 +2195,14 @@ snapshots: dependencies: copy-anything: 4.0.5 + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + tar-fs@2.1.4: dependencies: chownr: 1.1.4 diff --git a/src/index.ts b/src/index.ts index d9645ad..35f7825 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { Command, CommanderError } from 'commander' +import { Command, CommanderError, Option } from 'commander' import * as path from 'path' import * as fs from 'fs' import { grey, red } from 'colors' @@ -24,6 +24,24 @@ export function createProgram() { .option('-s, --schema ', 'Path to ZModel schema file', 'schema.zmodel') .option('-d, --datasource-url ', 'Datasource URL (overrides schema configuration)') .option('-l, --log ', 'Query log levels (e.g., query, info, warn, error)') + .option( + '--studioAuthKey ', + 'Authentication key from ZenStack Studio. When set, the proxy will only accept requests signed by your Studio project.\nCan also be set via the ZENSTACK_STUDIO_AUTH_KEY environment variable.' + ) + .addOption( + new Option( + '--signatureToleranceSecs ', + 'Maximum age (in seconds) of a signed request before it is rejected as a replay. Defaults to 60.' + ) + .default(60) + .argParser((v) => { + const parsed = parseInt(v, 10) + if (isNaN(parsed) || parsed < 0) { + throw new CliError(`--signatureToleranceSecs must be a positive integer, got: ${v}`) + } + return parsed + }) + ) .action(async (options) => { // Determine ZModel schema path const zmodelPath = path.isAbsolute(options.schema) @@ -47,6 +65,8 @@ export function createProgram() { zmodelConfig: zmodelConfig, zmodelSchemaDir: zmodelSchemaDir, logLevel: options.log, + studioAuthKey: options.studioAuthKey, + signatureToleranceSecs: options.signatureToleranceSecs, }) }) diff --git a/src/server.ts b/src/server.ts index 251c1e0..631e53f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,10 +4,11 @@ import cors from 'cors' import { ZenStackMiddleware } from '@zenstackhq/server/express' import { ZModelConfig } from './zmodel-parser' import { getNodeModulesFolder, getPrismaVersion, getZenStackVersion } from './utils/version-utils' -import { blue, grey, red } from 'colors' +import { blue, grey, red, yellow } from 'colors' import semver from 'semver' import { CliError } from './cli-error' import SuperJSON from 'superjson' +import { createSignatureMiddleware, normalizePublicKey } from './signature' export interface ServerOptions { zenstackPath: string | undefined @@ -15,8 +16,16 @@ export interface ServerOptions { zmodelConfig: ZModelConfig zmodelSchemaDir: string logLevel?: string[] + studioAuthKey?: string + signatureToleranceSecs: number } +/** + * Represents the identity claim embedded in the Authorization header. + * The bearer token is a plain base64-encoded JSON string. + */ +type UserClaim = { type: 'superUser' } | { type: 'user'; data: Record } + type EnhancementKind = 'password' | 'omit' | 'policy' | 'validation' | 'delegate' | 'encryption' // enable all enhancements except policy const Enhancements: EnhancementKind[] = ['password', 'omit', 'validation', 'delegate', 'encryption'] @@ -252,6 +261,56 @@ function processRequestPayload(args: any) { } } +/** + * Resolves the appropriate enhanced Prisma client for a request based on the Authorization header. + * + * - No publicAPIKey configured (authEnabled=false): return the standard enhanced client. + * - superUser claim: return the standard enhanced client (no policy enforcement). + * - Regular user claim: return the policy-enhanced client with the user identity. + * - No / invalid token: return the standard enhanced client. + */ +function resolveEnhancedClient( + prisma: any, + enhanceFunc: (prisma: any, ctx: any, opts: any) => any, + req: express.Request, + authEnabled: boolean +): any { + const baseClient = enhanceFunc(prisma, {}, { kinds: Enhancements }) + + const authHeader = req.headers['authorization'] + + if (!authEnabled && !authHeader) { + return baseClient + } + + if (!authHeader?.startsWith('Bearer ')) { + return baseClient + } + + const token = authHeader.substring(7) + let claim: UserClaim + try { + claim = JSON.parse(Buffer.from(token, 'base64').toString('utf8')) as UserClaim + } catch { + return baseClient + } + + if (claim.type === 'superUser') { + return baseClient + } + + if (claim.type === 'user') { + // Enable policy enforcement with the user's identity context. + return enhanceFunc( + prisma, + { user: claim.data }, + { kinds: [...Enhancements, 'policy'] as EnhancementKind[] } + ) + } + + return baseClient +} + async function handleTransaction(modelMeta: any, client: any, requestBody: unknown) { const processedOps: Array<{ model: string; op: string; args: unknown }> = [] if (!requestBody || !Array.isArray(requestBody) || requestBody.length === 0) { @@ -344,24 +403,43 @@ export async function startServer(options: ServerOptions) { throw new CliError('Database connection failed: ' + err) } + // Warn when running without authentication. + const studioAuthKey = options.studioAuthKey ?? process.env['ZENSTACK_STUDIO_AUTH_KEY'] + if (!studioAuthKey) { + console.warn( + yellow( + 'Warning: This proxy has no authentication. Do not expose it to the public network.\n' + + 'To secure it, get an API key from ZenStack Studio and set it via the ZENSTACK_STUDIO_AUTH_KEY environment variable.' + ) + ) + } + const app = express() app.use(cors()) - app.use(express.json({ limit: '5mb' })) + app.use( + express.json({ + limit: '5mb', + verify: (req, _res, buf) => { + // Capture the raw body string for use in signature verification. + ;(req as express.Request & { rawBody?: string }).rawBody = buf.toString('utf8') + }, + }) + ) app.use(express.urlencoded({ extended: true, limit: '5mb' })) + if (studioAuthKey) { + const toleranceSecs = options.signatureToleranceSecs ?? 60 + const normalizedKey = normalizePublicKey(studioAuthKey) + app.use(['/api/model', '/api/schema'], createSignatureMiddleware(normalizedKey, toleranceSecs)) + } + // ZenStack API endpoint app.post('/api/model/\\$transaction/sequential', async (_req, res) => { const response = await handleTransaction( modelMeta, - enhanceFunc( - prisma, - {}, - { - kinds: Enhancements, - } - ), + resolveEnhancedClient(prisma, enhanceFunc, _req, !!studioAuthKey), _req.body ) res.status(response.status).json(response.body) @@ -370,14 +448,8 @@ export async function startServer(options: ServerOptions) { app.use( '/api/model', ZenStackMiddleware({ - getPrisma: () => { - return enhanceFunc( - prisma, - {}, - { - kinds: Enhancements, - } - ) + getPrisma: (req) => { + return resolveEnhancedClient(prisma, enhanceFunc, req, !!studioAuthKey) }, }) ) diff --git a/src/signature.ts b/src/signature.ts new file mode 100644 index 0000000..452ca29 --- /dev/null +++ b/src/signature.ts @@ -0,0 +1,112 @@ +import { verify } from 'node:crypto' +import express from 'express' +import { yellow } from 'colors' + +/** + * Accepts a public key in either PEM format or as a raw base64 / base64url DER string + * (without the `-----BEGIN PUBLIC KEY-----` markers) and always returns a PEM string. + */ +export function normalizePublicKey(key: string): string { + key = key.trim() + if (key.startsWith('-----BEGIN PUBLIC KEY-----')) { + return key + } + // Convert base64url → standard base64, then wrap in PEM markers. + const b64 = key.replace(/-/g, '+').replace(/_/g, '/') + return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----` +} + +/** + * Verifies an ed25519 signature. + * + * @param publicKey PEM-encoded public key (use normalizePublicKey first) + * @param message The message that was signed + * @param sig The base64url-encoded signature (the value after `v1=`) + * @returns true if the signature is valid, false otherwise + */ +export function verifyEd25519Signature(publicKey: string, message: string, sig: string): boolean { + try { + return verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url')) + } catch { + return false + } +} + +/** + * Creates an Express middleware that verifies the ed25519 signature on every request. + * + * Signature header format: `x-zenstack-signature: t=,v1=` + * + * The signed message is constructed as: + * - GET / DELETE requests: `[]` + * - Other methods: `[]` + * + * `authorizationToken` is the bearer token value from the `Authorization` header (if present). + */ +export function createSignatureMiddleware(publicKey: string, toleranceSeconds: number) { + // Throttle invalid-signature warnings to at most once per 60 seconds. + let lastInvalidSigWarnAt = 0 + const WARN_THROTTLE_SECS = 60 + + function warnInvalidSignature() { + const now = Math.floor(Date.now() / 1000) + if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) { + lastInvalidSigWarnAt = now + console.warn( + yellow( + 'Warning: Received a request with an invalid signature. ' + + 'Please double-check whether you have the correct public API key configured.' + ) + ) + } + } + + return (req: express.Request, res: express.Response, next: express.NextFunction) => { + const signatureHeader = req.headers['x-zenstack-signature'] + if (!signatureHeader || typeof signatureHeader !== 'string') { + return res.status(401).json({ message: 'Missing x-zenstack-signature header' }) + } + + const parts = signatureHeader.split(',') + const timestampPart = parts.find((p) => p.startsWith('t=')) + const sigPart = parts.find((p) => p.startsWith('v1=')) + if (!timestampPart || !sigPart) { + return res.status(401).json({ message: 'Invalid x-zenstack-signature format' }) + } + const timestamp = timestampPart.substring(2) + const sig = sigPart.substring(3) + + // Replay-attack prevention: reject requests whose timestamp deviates + // from server time by more than the configured tolerance. + const requestTime = parseInt(timestamp, 10) + const now = Math.floor(Date.now() / 1000) + if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) { + return res.status(401).json({ message: 'Request timestamp is expired or invalid' }) + } + + // Payload: raw query string for GET/DELETE, raw body for other methods. + let payload: string + if (req.method === 'GET' || req.method === 'DELETE') { + const qMark = req.originalUrl.indexOf('?') + payload = qMark >= 0 ? req.originalUrl.substring(qMark + 1) : '' + } else { + payload = (req as express.Request & { rawBody?: string }).rawBody ?? '' + } + + // authorizationToken is the bearer token value (if present). + const authHeader = req.headers['authorization'] + const authorizationToken = + authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : undefined + + const message = authorizationToken + ? `${payload}${timestamp}${authorizationToken}` + : `${payload}${timestamp}` + + if (!verifyEd25519Signature(publicKey, message, sig)) { + warnInvalidSignature() + return res.status(401).json({ message: 'Invalid signature' }) + } + + return next() + } +} diff --git a/test/signature.test.ts b/test/signature.test.ts new file mode 100644 index 0000000..56487ca --- /dev/null +++ b/test/signature.test.ts @@ -0,0 +1,401 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import express from 'express' +import { sign } from 'node:crypto' +import request from 'supertest' +import { + createSignatureMiddleware, + normalizePublicKey, + verifyEd25519Signature, +} from '../src/signature' + +// ─── Ed25519 key pair for tests ─────────────────────────────────────────────── + +const TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIHIlHXhk+zc9ziuvrYAnZZgGL36H1GXwfsYchM9dM8gR +-----END PRIVATE KEY-----` + +const TEST_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAFSJV7wjdFuDz2CqYX7hGnITQvcmJYy7OJQq2Cy2Eiqs= +-----END PUBLIC KEY-----` + +/** Raw base64 DER — the same key without PEM markers. */ +const TEST_PUBLIC_KEY_DER = 'MCowBQYDK2VwAyEAFSJV7wjdFuDz2CqYX7hGnITQvcmJYy7OJQq2Cy2Eiqs=' + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Builds the `x-zenstack-signature` header value for a request. + */ +function buildSignatureHeader(options: { + privateKey: string + method: string + pathWithQuery: string + body?: unknown + authorizationToken?: string + timestamp?: string +}): string { + const timestamp = options.timestamp ?? String(Math.floor(Date.now() / 1000)) + const method = options.method.toUpperCase() + let payload: string + if (method === 'GET' || method === 'DELETE') { + const qMark = options.pathWithQuery.indexOf('?') + payload = qMark >= 0 ? options.pathWithQuery.substring(qMark + 1) : '' + } else { + payload = options.body != null ? JSON.stringify(options.body) : '' + } + + const message = options.authorizationToken + ? `${payload}${timestamp}${options.authorizationToken}` + : `${payload}${timestamp}` + + const sig = sign(null, Buffer.from(message, 'utf8'), options.privateKey).toString('base64url') + return `t=${timestamp},v1=${sig}` +} + +// ─── normalizePublicKey ──────────────────────────────────────────────────────── + +describe('normalizePublicKey', () => { + it('returns PEM key unchanged', () => { + const result = normalizePublicKey(TEST_PUBLIC_KEY) + assert.strictEqual(result, TEST_PUBLIC_KEY) + }) + + it('wraps raw base64 DER in PEM markers', () => { + const result = normalizePublicKey(TEST_PUBLIC_KEY_DER) + assert.ok(result.includes('-----BEGIN PUBLIC KEY-----')) + assert.ok(result.includes('-----END PUBLIC KEY-----')) + assert.ok(result.includes(TEST_PUBLIC_KEY_DER)) + }) + + it('converts base64url to standard base64 before wrapping', () => { + const base64url = TEST_PUBLIC_KEY_DER.replace(/\+/g, '-').replace(/\//g, '_') + const result = normalizePublicKey(base64url) + // After normalization, the body should be standard base64 (no `-` or `_`) + const body = result + .replace('-----BEGIN PUBLIC KEY-----\n', '') + .replace('\n-----END PUBLIC KEY-----', '') + assert.doesNotMatch(body, /[-_]/) + }) + + it('trims leading/trailing whitespace', () => { + const result = normalizePublicKey(` ${TEST_PUBLIC_KEY_DER} `) + assert.ok(result.includes('-----BEGIN PUBLIC KEY-----')) + assert.ok(result.includes('-----END PUBLIC KEY-----')) + }) +}) + +// ─── verifyEd25519Signature ──────────────────────────────────────────────────── + +describe('verifyEd25519Signature', () => { + const normalizedKey = normalizePublicKey(TEST_PUBLIC_KEY) + + it('returns true for a valid signature', () => { + const message = 'hello world' + const sig = sign(null, Buffer.from(message, 'utf8'), TEST_PRIVATE_KEY).toString('base64url') + assert.ok(verifyEd25519Signature(normalizedKey, message, sig)) + }) + + it('returns false for a tampered message', () => { + const message = 'hello world' + const sig = sign(null, Buffer.from(message, 'utf8'), TEST_PRIVATE_KEY).toString('base64url') + assert.ok(!verifyEd25519Signature(normalizedKey, 'tampered message', sig)) + }) + + it('returns false for a garbage signature', () => { + assert.ok(!verifyEd25519Signature(normalizedKey, 'hello', 'notavalidsig')) + }) + + it('returns false for an empty signature', () => { + assert.ok(!verifyEd25519Signature(normalizedKey, 'hello', '')) + }) + + it('works with a raw DER key after normalization', () => { + const message = 'test-message' + const sig = sign(null, Buffer.from(message, 'utf8'), TEST_PRIVATE_KEY).toString('base64url') + assert.ok(verifyEd25519Signature(normalizePublicKey(TEST_PUBLIC_KEY_DER), message, sig)) + }) +}) + +// ─── createSignatureMiddleware ───────────────────────────────────────────────── + +describe('createSignatureMiddleware', () => { + function buildApp(publicKey: string, toleranceSecs = 60) { + const app = express() + app.use( + express.json({ + verify: (req, _res, buf) => { + ;(req as express.Request & { rawBody?: string }).rawBody = buf.toString('utf8') + }, + }) + ) + app.use(createSignatureMiddleware(normalizePublicKey(publicKey), toleranceSecs)) + app.get('/ping', (_req, res) => res.json({ ok: true })) + app.post('/ping', (_req, res) => res.json({ ok: true })) + app.put('/ping', (_req, res) => res.json({ ok: true })) + return app + } + + describe('missing / malformed header', () => { + it('returns 401 when x-zenstack-signature header is absent', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const res = await request(app).get('/ping') + assert.strictEqual(res.status, 401) + assert.match(res.body.message, /missing/i) + }) + + it('returns 401 when the header format is invalid', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const res = await request(app).get('/ping').set('x-zenstack-signature', 'garbage') + assert.strictEqual(res.status, 401) + assert.match(res.body.message, /invalid.*format/i) + }) + + it('returns 401 when t= part is missing', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const res = await request(app).get('/ping').set('x-zenstack-signature', 'v1=abc') + assert.strictEqual(res.status, 401) + }) + + it('returns 401 when v1= part is missing', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const res = await request(app) + .get('/ping') + .set('x-zenstack-signature', `t=${Math.floor(Date.now() / 1000)}`) + assert.strictEqual(res.status, 401) + }) + }) + + describe('timestamp validation', () => { + it('returns 401 when timestamp is too old (default 60s window)', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const expiredTimestamp = String(Math.floor(Date.now() / 1000) - 120) + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery: '/ping', + timestamp: expiredTimestamp, + }) + const res = await request(app).get('/ping').set('x-zenstack-signature', sig) + assert.strictEqual(res.status, 401) + assert.match(res.body.message, /expired/i) + }) + + it('returns 401 when timestamp is too far in the future', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const futureTimestamp = String(Math.floor(Date.now() / 1000) + 120) + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery: '/ping', + timestamp: futureTimestamp, + }) + const res = await request(app).get('/ping').set('x-zenstack-signature', sig) + assert.strictEqual(res.status, 401) + assert.match(res.body.message, /expired/i) + }) + + it('accepts a request within the custom tolerance window', async () => { + const app = buildApp(TEST_PUBLIC_KEY, 300) + const timestamp = String(Math.floor(Date.now() / 1000) - 120) + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery: '/ping', + timestamp, + }) + const res = await request(app).get('/ping').set('x-zenstack-signature', sig) + assert.strictEqual(res.status, 200) + }) + + it('rejects a request outside a tight custom tolerance', async () => { + const app = buildApp(TEST_PUBLIC_KEY, 5) + const timestamp = String(Math.floor(Date.now() / 1000) - 10) + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery: '/ping', + timestamp, + }) + const res = await request(app).get('/ping').set('x-zenstack-signature', sig) + assert.strictEqual(res.status, 401) + }) + }) + + describe('GET request signature', () => { + it('accepts a valid GET request with no query params', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery: '/ping', + }) + const res = await request(app).get('/ping').set('x-zenstack-signature', sig) + assert.strictEqual(res.status, 200) + }) + + it('accepts a valid GET request with query params', async () => { + const appInner = express() + appInner.use( + express.json({ + verify: (req, _res, buf) => { + ;(req as express.Request & { rawBody?: string }).rawBody = buf.toString('utf8') + }, + }) + ) + appInner.use(createSignatureMiddleware(normalizePublicKey(TEST_PUBLIC_KEY), 60)) + appInner.get('/search', (_req, res) => res.json({ ok: true })) + + const pathWithQuery = '/search?q=hello%20world&page=1' + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery, + }) + const res = await request(appInner).get(pathWithQuery).set('x-zenstack-signature', sig) + assert.strictEqual(res.status, 200) + }) + + it('rejects a GET request when query string is tampered', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + // Sign with original query, then send a different query + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery: '/ping?foo=bar', + }) + const res = await request(app).get('/ping?foo=tampered').set('x-zenstack-signature', sig) + assert.strictEqual(res.status, 401) + }) + }) + + describe('POST request signature', () => { + it('accepts a valid POST request with a JSON body', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const body = { data: { email: 'alice@example.com' } } + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'POST', + pathWithQuery: '/ping', + body, + }) + const res = await request(app) + .post('/ping') + .set('x-zenstack-signature', sig) + .set('Content-Type', 'application/json') + .send(body) + assert.strictEqual(res.status, 200) + }) + + it('rejects a POST request when the body is tampered', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const originalBody = { data: { email: 'alice@example.com' } } + const tamperedBody = { data: { email: 'evil@example.com' } } + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'POST', + pathWithQuery: '/ping', + body: originalBody, + }) + const res = await request(app) + .post('/ping') + .set('x-zenstack-signature', sig) + .set('Content-Type', 'application/json') + .send(tamperedBody) + assert.strictEqual(res.status, 401) + }) + }) + + describe('PUT request signature', () => { + it('accepts a valid PUT request', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const body = { where: { id: 'u1' }, data: { email: 'new@example.com' } } + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'PUT', + pathWithQuery: '/ping', + body, + }) + const res = await request(app) + .put('/ping') + .set('x-zenstack-signature', sig) + .set('Content-Type', 'application/json') + .send(body) + assert.strictEqual(res.status, 200) + }) + }) + + describe('Authorization header is included in the signed message', () => { + it('rejects a request when the signature does not cover the Authorization header', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const authToken = Buffer.from(JSON.stringify({ type: 'superUser' })).toString('base64') + // Sign WITHOUT including the auth token + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery: '/ping', + }) + const res = await request(app) + .get('/ping') + .set('x-zenstack-signature', sig) + .set('Authorization', `Bearer ${authToken}`) + assert.strictEqual(res.status, 401) + }) + + it('accepts a request when the signature covers the Authorization header', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const authToken = Buffer.from(JSON.stringify({ type: 'superUser' })).toString('base64') + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery: '/ping', + authorizationToken: authToken, + }) + const res = await request(app) + .get('/ping') + .set('x-zenstack-signature', sig) + .set('Authorization', `Bearer ${authToken}`) + assert.strictEqual(res.status, 200) + }) + + it('rejects when the auth token is swapped after signing', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const originalToken = Buffer.from(JSON.stringify({ type: 'superUser' })).toString('base64') + const differentToken = Buffer.from( + JSON.stringify({ type: 'user', data: { id: 'u1' } }) + ).toString('base64') + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery: '/ping', + authorizationToken: originalToken, + }) + const res = await request(app) + .get('/ping') + .set('x-zenstack-signature', sig) + .set('Authorization', `Bearer ${differentToken}`) + assert.strictEqual(res.status, 401) + }) + }) + + describe('public key format', () => { + it('accepts a raw base64 DER key (without PEM markers)', async () => { + const app = buildApp(TEST_PUBLIC_KEY_DER) + const sig = buildSignatureHeader({ + privateKey: TEST_PRIVATE_KEY, + method: 'GET', + pathWithQuery: '/ping', + }) + const res = await request(app).get('/ping').set('x-zenstack-signature', sig) + assert.strictEqual(res.status, 200) + }) + + it('rejects an invalid signature with the correct key format', async () => { + const app = buildApp(TEST_PUBLIC_KEY) + const res = await request(app) + .get('/ping') + .set('x-zenstack-signature', `t=${Math.floor(Date.now() / 1000)},v1=invalidsignature`) + assert.strictEqual(res.status, 401) + }) + }) +})