diff --git a/.env.example b/.env.example index df8bc13..efee80c 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ DATABASE_URL="file:./prisma/dev.db" FRONTEND_ORIGIN="http://localhost:5173" PORT=3000 +JWT_SECRET="replace-with-a-secret-at-least-32-characters-long" diff --git a/dev.db b/dev.db new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index 4e33a30..9e31e9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,13 +12,19 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "11.4.5", "@prisma/adapter-better-sqlite3": "^7.9.1", "@prisma/client": "^7.9.1", + "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "cookie-parser": "^1.4.7", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -28,9 +34,12 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", @@ -2343,6 +2352,19 @@ } } }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/mapped-types": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", @@ -2363,6 +2385,16 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.29", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.29.tgz", @@ -3141,6 +3173,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -3162,6 +3204,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -3371,6 +3423,16 @@ "dev": true, "license": "MIT" }, + "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==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/lodash": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", @@ -3385,16 +3447,53 @@ "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==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -4532,6 +4631,20 @@ "node": ">=6.0.0" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/better-result": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", @@ -4702,6 +4815,12 @@ "ieee754": "^1.1.13" } }, + "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", @@ -5217,6 +5336,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -5693,6 +5831,15 @@ "dev": true, "license": "MIT" }, + "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", @@ -8222,6 +8369,49 @@ "graceful-fs": "^4.1.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/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/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8324,6 +8514,42 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "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", @@ -8338,6 +8564,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/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -8944,6 +9176,15 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -8954,6 +9195,17 @@ "lodash": "^4.17.21" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -9191,6 +9443,42 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -9275,6 +9563,11 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", @@ -11205,7 +11498,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -11312,6 +11604,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index a701c13..28065ab 100644 --- a/package.json +++ b/package.json @@ -17,19 +17,25 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json" + "test:e2e": "DATABASE_URL=file:/tmp/wealth-ledger-e2e.db prisma migrate deploy && NODE_ENV=development NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json" }, "dependencies": { "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "11.4.5", "@prisma/adapter-better-sqlite3": "^7.9.1", "@prisma/client": "^7.9.1", + "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "cookie-parser": "^1.4.7", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -39,9 +45,12 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", diff --git a/prisma/migrations/20260813063823_add_authentication/migration.sql b/prisma/migrations/20260813063823_add_authentication/migration.sql new file mode 100644 index 0000000..2da4930 --- /dev/null +++ b/prisma/migrations/20260813063823_add_authentication/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "refreshTokenHash" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "revokedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 38b18ec..7631b5c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,3 +7,25 @@ generator client { datasource db { provider = "sqlite" } + +model User { + id String @id + email String @unique + passwordHash String + sessions Session[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Session { + id String @id + userId String + refreshTokenHash String + expiresAt DateTime + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts deleted file mode 100644 index a407de3..0000000 --- a/src/app.controller.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); - - appController = app.get(AppController); - }); - - it('documents the safe internal error response', async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); - const app: INestApplication = module.createNestApplication(); - app.setGlobalPrefix('api/v1'); - - const document: OpenAPIObject = SwaggerModule.createDocument( - app, - new DocumentBuilder().build(), - ); - - expect(document.paths['/api/v1']?.get?.responses['500']).toEqual({ - description: 'Internal server error', - content: { - 'application/json': { - schema: { $ref: '#/components/schemas/SafeErrorResponseDto' }, - }, - }, - }); - - expect(document.paths['/api/v1']?.get?.responses['200']).toEqual( - expect.objectContaining({ - content: { - 'application/json': { - schema: { type: 'string', example: 'Hello World!' }, - }, - }, - }), - ); - - await app.close(); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); -}); diff --git a/src/app.controller.ts b/src/app.controller.ts deleted file mode 100644 index 4447ce4..0000000 --- a/src/app.controller.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiOkResponse } from '@nestjs/swagger'; -import { ApiSafeErrorResponse } from './common/decorators/api-safe-error-response.decorator'; -import { AppService } from './app.service'; - -@Controller() -export class AppController { - constructor(private readonly appService: AppService) {} - - @Get() - @ApiOkResponse({ - schema: { type: 'string', example: 'Hello World!' }, - }) - @ApiSafeErrorResponse() - getHello(): string { - return this.appService.getHello(); - } -} diff --git a/src/app.module.ts b/src/app.module.ts index 23ada66..5b03e86 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,7 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { AuthModule } from './auth/auth.module'; import { validateEnv } from './config/env.validation'; import { PrismaModule } from './prisma/prisma.module'; @@ -12,8 +11,7 @@ import { PrismaModule } from './prisma/prisma.module'; validate: validateEnv, }), PrismaModule, + AuthModule, ], - controllers: [AppController], - providers: [AppService], }) export class AppModule {} diff --git a/src/app.service.ts b/src/app.service.ts deleted file mode 100644 index 927d7cc..0000000 --- a/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..f8a5ef9 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,138 @@ +import { + Body, + Controller, + Get, + HttpCode, + Post, + Req, + Res, + UseGuards, + UnauthorizedException, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiNoContentResponse, + ApiOkResponse, + ApiProperty, + ApiTags, +} from '@nestjs/swagger'; +import type { Request, Response } from 'express'; +import { CredentialsDto } from './auth.dto'; +import { AuthService } from './auth.service'; +import { AccessPayload } from './auth.types'; +import { JwtAuthGuard } from './jwt-auth.guard'; + +const REFRESH_COOKIE = 'refresh_token'; +const COOKIE_PATH = '/api/v1/auth'; + +class UserDto { + @ApiProperty() + id: string; + + @ApiProperty() + email: string; +} + +class AuthenticationDto { + @ApiProperty() + accessToken: string; + + @ApiProperty({ type: UserDto }) + user: UserDto; +} + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor(private readonly auth: AuthService) {} + + @Post('register') + @ApiOkResponse({ type: AuthenticationDto }) + async register( + @Body() credentials: CredentialsDto, + @Res({ passthrough: true }) response: Response, + ) { + return this.respond( + response, + await this.auth.register(credentials.email, credentials.password), + ); + } + + @Post('login') + @ApiOkResponse({ type: AuthenticationDto }) + async login( + @Body() credentials: CredentialsDto, + @Res({ passthrough: true }) response: Response, + ) { + return this.respond( + response, + await this.auth.login(credentials.email, credentials.password), + ); + } + + @Post('refresh') + @ApiOkResponse({ type: AuthenticationDto }) + async refresh( + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + ) { + try { + const token = this.refreshToken(request); + if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + return this.respond(response, await this.auth.refresh(token)); + } catch (error) { + this.clearCookie(response); + throw error; + } + } + + @Post('logout') + @HttpCode(204) + @ApiNoContentResponse() + async logout( + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + ): Promise { + await this.auth.logout(this.refreshToken(request)); + this.clearCookie(response); + } + + @Get('me') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOkResponse({ type: UserDto }) + async me(@Req() request: Request & { user: AccessPayload }) { + const user = await this.auth.userById(request.user.sub); + return this.auth.toUser(user); + } + + private respond( + response: Response, + result: { + user: { id: string; email: string }; + accessToken: string; + refreshToken: string; + }, + ) { + response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions()); + return { user: result.user, accessToken: result.accessToken }; + } + + private clearCookie(response: Response): void { + response.clearCookie(REFRESH_COOKIE, this.cookieOptions()); + } + + private refreshToken(request: Request): string | undefined { + const token: unknown = request.cookies?.[REFRESH_COOKIE]; + return typeof token === 'string' ? token : undefined; + } + + private cookieOptions() { + return { + httpOnly: true, + sameSite: 'lax' as const, + secure: process.env['NODE_ENV'] !== 'development', + path: COOKIE_PATH, + }; + } +} diff --git a/src/auth/auth.dto.ts b/src/auth/auth.dto.ts new file mode 100644 index 0000000..3902d0f --- /dev/null +++ b/src/auth/auth.dto.ts @@ -0,0 +1,57 @@ +import { + IsString, + MinLength, + Validate, + ValidatorConstraint, +} from 'class-validator'; +import { Transform } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +@ValidatorConstraint({ name: 'email' }) +class EmailConstraint { + validate(value: unknown): boolean { + return ( + typeof value === 'string' && + value.length <= 254 && + value.split('@').length === 2 && + !value + .split('') + .some( + (character) => + /\s/.test(character) || + character.charCodeAt(0) < 32 || + character.charCodeAt(0) === 127, + ) && + value.split('@').every(Boolean) + ); + } + defaultMessage(): string { + return 'must be a valid email address'; + } +} + +@ValidatorConstraint({ name: 'passwordBytes' }) +class PasswordBytesConstraint { + validate(value: unknown): boolean { + return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 72; + } + defaultMessage(): string { + return 'must not exceed 72 UTF-8 bytes'; + } +} + +export class CredentialsDto { + @ApiProperty({ example: 'user@example.com' }) + @IsString() + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim().toLowerCase() : value, + ) + @Validate(EmailConstraint) + email: string; + + @ApiProperty({ minLength: 12, maxLength: 72, example: 'a secure password' }) + @IsString() + @MinLength(12) + @Validate(PasswordBytesConstraint) + password: string; +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..d1b0ee0 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { Environment } from '../config/env.validation'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { JwtStrategy } from './jwt.strategy'; + +@Module({ + imports: [ + PassportModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.getOrThrow('JWT_SECRET'), + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy, JwtAuthGuard], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..be633c7 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,184 @@ +import { + ConflictException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; +import { createHash, randomUUID } from 'crypto'; +import { Session, User } from '../../generated/prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthUser, RefreshPayload } from './auth.types'; + +const ACCESS_TTL = '15m'; +const REFRESH_TTL = '7d'; +const DUMMY_PASSWORD_HASH = + '$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6'; + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + ) {} + + async register( + email: string, + password: string, + ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + const normalizedEmail = this.normalizeEmail(email); + const existing = await this.prisma.user.findUnique({ + where: { email: normalizedEmail }, + }); + if (existing) + throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + + const user = await this.prisma.user.create({ + data: { + id: randomUUID(), + email: normalizedEmail, + passwordHash: await bcrypt.hash(password, 12), + }, + }); + return this.createSession(user); + } + + async login( + email: string, + password: string, + ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + const user = await this.prisma.user.findUnique({ + where: { email: this.normalizeEmail(email) }, + }); + const valid = await bcrypt.compare( + password, + user?.passwordHash ?? DUMMY_PASSWORD_HASH, + ); + if (!user || !valid) + throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' }); + return this.createSession(user); + } + + async refresh( + token: string, + ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + let payload: RefreshPayload; + try { + payload = await this.jwt.verifyAsync(token, { + algorithms: ['HS256'], + }); + } catch { + throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } + if (payload.type !== 'refresh') + throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + const session = await this.prisma.session.findUnique({ + where: { id: payload.sid }, + include: { user: true }, + }); + if ( + !session || + session.userId !== payload.sub || + session.revokedAt || + session.expiresAt <= new Date() + ) { + if (session && !session.revokedAt) + await this.prisma.session.delete({ where: { id: session.id } }); + throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } + if (session.refreshTokenHash !== this.hash(token)) { + await this.prisma.session.update({ + where: { id: session.id }, + data: { revokedAt: new Date() }, + }); + throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } + return this.rotateSession(session); + } + + async logout(token?: string): Promise { + if (!token) return; + try { + const payload = await this.jwt.verifyAsync(token, { + algorithms: ['HS256'], + }); + if (payload.type !== 'refresh') return; + const session = await this.prisma.session.findUnique({ + where: { id: payload.sid }, + }); + if ( + session && + !session.revokedAt && + session.refreshTokenHash === this.hash(token) + ) { + await this.prisma.session.update({ + where: { id: session.id }, + data: { revokedAt: new Date() }, + }); + } + } catch { + // Logout is intentionally idempotent. + } + } + + toUser(user: User): AuthUser { + return { id: user.id, email: user.email }; + } + + async userById(id: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id } }); + if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + return user; + } + + private async createSession( + user: User, + ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + const session = await this.prisma.session.create({ + data: { + id: randomUUID(), + userId: user.id, + refreshTokenHash: '', + expiresAt: new Date(), + }, + }); + return this.rotateSession({ ...session, user }); + } + + private async rotateSession( + session: Session & { user: User }, + ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + const refreshToken = await this.jwt.signAsync( + { + sub: session.userId, + sid: session.id, + type: 'refresh', + jti: randomUUID(), + }, + { expiresIn: REFRESH_TTL, algorithm: 'HS256' }, + ); + await this.prisma.session.update({ + where: { id: session.id }, + data: { + refreshTokenHash: this.hash(refreshToken), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); + return { + user: this.toUser(session.user), + accessToken: await this.jwt.signAsync( + { sub: session.userId, sid: session.id, type: 'access' }, + { expiresIn: ACCESS_TTL, algorithm: 'HS256' }, + ), + refreshToken, + }; + } + + private normalizeEmail(email: string): string { + return email; + } + + private hash(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } +} diff --git a/src/auth/auth.types.ts b/src/auth/auth.types.ts new file mode 100644 index 0000000..cbeba59 --- /dev/null +++ b/src/auth/auth.types.ts @@ -0,0 +1,17 @@ +export interface AuthUser { + id: string; + email: string; +} + +export interface AccessPayload { + sub: string; + sid: string; + type: 'access'; +} + +export interface RefreshPayload { + sub: string; + sid: string; + type: 'refresh'; + jti: string; +} diff --git a/src/auth/jwt-auth.guard.ts b/src/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/src/auth/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts new file mode 100644 index 0000000..0bcbb41 --- /dev/null +++ b/src/auth/jwt.strategy.ts @@ -0,0 +1,24 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { Environment } from '../config/env.validation'; +import { AccessPayload } from './auth.types'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(config: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: config.getOrThrow('JWT_SECRET'), + algorithms: ['HS256'], + }); + } + + validate(payload: AccessPayload): AccessPayload { + if (payload.type !== 'access') + throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + return payload; + } +} diff --git a/src/common/filters/http-exception.filter.spec.ts b/src/common/filters/http-exception.filter.spec.ts index 3b95d90..869131e 100644 --- a/src/common/filters/http-exception.filter.spec.ts +++ b/src/common/filters/http-exception.filter.spec.ts @@ -4,25 +4,22 @@ import { HttpExceptionFilter } from './http-exception.filter'; describe('HttpExceptionFilter', () => { it('does not expose internal error details', () => { const status = jest.fn().mockReturnThis(); + const type = jest.fn().mockReturnThis(); const json = jest.fn(); const filter = new HttpExceptionFilter(); const host = { switchToHttp: () => ({ - getRequest: () => ({ - path: '/api/v1/health', - url: '/api/v1/health?access_token=secret', - }), - getResponse: () => ({ status, json }), + getResponse: () => ({ status, type, json }), }), } as unknown as ArgumentsHost; filter.catch(new Error('database password: secret'), host); expect(status).toHaveBeenCalledWith(500); + expect(type).toHaveBeenCalledWith('application/problem+json'); expect(json).toHaveBeenCalledWith({ - statusCode: 500, - message: 'Internal server error', - path: '/api/v1/health', + status: 500, + code: 'INTERNAL_SERVER_ERROR', }); }); }); diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index 0404cf4..353b37b 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -4,24 +4,65 @@ import { ExceptionFilter, HttpException, HttpStatus, + ValidationError, } from '@nestjs/common'; -import { Request, Response } from 'express'; +import type { Response } from 'express'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { const context = host.switchToHttp(); const response = context.getResponse(); - const request = context.getRequest(); const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; - response.status(status).json({ - statusCode: status, - message: status === 500 ? 'Internal server error' : 'Request failed', - path: request.path, - }); + const payload = + exception instanceof HttpException ? exception.getResponse() : undefined; + const isValidation = Array.isArray( + (payload as { message?: unknown })?.message, + ); + response + .status(status) + .type('application/problem+json') + .json({ + status, + code: isValidation ? 'VALIDATION_ERROR' : this.codeFor(status, payload), + ...(isValidation + ? { + errors: this.validationErrors( + (payload as { message: ValidationError[] }).message, + ), + } + : {}), + }); + } + + private codeFor(status: number, payload: unknown): string { + if (typeof payload === 'object' && payload !== null && 'code' in payload) { + const code = (payload as { code?: unknown }).code; + if (typeof code === 'string') return code; + } + return ( + { + 400: 'BAD_REQUEST', + 401: 'UNAUTHORIZED', + 404: 'NOT_FOUND', + 415: 'UNSUPPORTED_MEDIA_TYPE', + 500: 'INTERNAL_SERVER_ERROR', + }[status] ?? 'INTERNAL_SERVER_ERROR' + ); + } + + private validationErrors( + errors: ValidationError[], + ): { pointer: string; detail: string }[] { + return errors.flatMap((error) => + Object.values(error.constraints ?? {}).map((detail) => ({ + pointer: `#/${error.property}`, + detail, + })), + ); } } diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts index 9cb1302..1368096 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -6,11 +6,13 @@ describe('validateEnv', () => { validateEnv({ DATABASE_URL: 'file:./data.db', FRONTEND_ORIGIN: 'http://localhost:5173/', + JWT_SECRET: 'a-secret-that-is-at-least-thirty-two-characters', }), ).toEqual({ DATABASE_URL: 'file:./data.db', FRONTEND_ORIGIN: 'http://localhost:5173', PORT: 3000, + JWT_SECRET: 'a-secret-that-is-at-least-thirty-two-characters', }); }); @@ -32,6 +34,7 @@ describe('validateEnv', () => { validateEnv({ DATABASE_URL: 'file:./data.db', FRONTEND_ORIGIN: origin, + JWT_SECRET: 'a-secret-that-is-at-least-thirty-two-characters', }), ).toThrow('FRONTEND_ORIGIN must be a valid HTTP origin'); }, diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index a864b64..0d47ebf 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -1,6 +1,7 @@ export interface Environment { DATABASE_URL: string; FRONTEND_ORIGIN: string; + JWT_SECRET: string; PORT: number; } @@ -8,6 +9,7 @@ export function validateEnv(config: Record): Environment { const databaseUrl = config['DATABASE_URL']; const frontendOrigin = config['FRONTEND_ORIGIN']; const port = config['PORT'] ?? 3000; + const jwtSecret = config['JWT_SECRET']; if (typeof databaseUrl !== 'string' || !databaseUrl.startsWith('file:')) { throw new Error('DATABASE_URL must be a SQLite file URL'); @@ -38,9 +40,14 @@ export function validateEnv(config: Record): Environment { throw new Error('PORT must be a valid port number'); } + if (typeof jwtSecret !== 'string' || jwtSecret.length < 32) { + throw new Error('JWT_SECRET must be at least 32 characters long'); + } + return { DATABASE_URL: databaseUrl, FRONTEND_ORIGIN: parsedOrigin.origin, + JWT_SECRET: jwtSecret, PORT: parsedPort, }; } diff --git a/src/main.ts b/src/main.ts index c35d179..5942b93 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,6 @@ +import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import cookieParser from 'cookie-parser'; import { ConfigService } from '@nestjs/config'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; @@ -11,8 +13,10 @@ async function bootstrap() { const config = app.get>(ConfigService); const frontendOrigin = config.getOrThrow('FRONTEND_ORIGIN'); - app.enableCors({ origin: frontendOrigin }); + app.enableCors({ origin: frontendOrigin, credentials: true }); app.setGlobalPrefix('api/v1'); + app.use(cookieParser()); + app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.useGlobalFilters(new HttpExceptionFilter()); if (shouldExposeSwagger(process.env['NODE_ENV'])) { diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 36852c5..a28e8b2 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,10 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import cookieParser from 'cookie-parser'; import request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from './../src/app.module'; +import { HttpExceptionFilter } from './../src/common/filters/http-exception.filter'; +import { PrismaService } from './../src/prisma/prisma.service'; -describe('AppController (e2e)', () => { +type Authentication = { user: { email: string }; accessToken: string }; +type Problem = { code: string }; + +describe('Authentication (e2e)', () => { let app: INestApplication; beforeEach(async () => { @@ -13,13 +19,67 @@ describe('AppController (e2e)', () => { }).compile(); app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('api/v1'); + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ transform: true, whitelist: true }), + ); + app.useGlobalFilters(new HttpExceptionFilter()); await app.init(); + await app.get(PrismaService).session.deleteMany(); + await app.get(PrismaService).user.deleteMany(); }); - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') + afterEach(() => app.close()); + + it('registers, guards profile, rotates refresh and logs out', async () => { + const agent = request.agent(app.getHttpServer()); + const registration = await agent + .post('/api/v1/auth/register') + .send({ email: ' User@Example.com ', password: 'a secure password' }) + .expect(201); + + const authentication = registration.body as Authentication; + expect(authentication.user.email).toBe('user@example.com'); + const accessToken = authentication.accessToken; + await request(app.getHttpServer()).get('/api/v1/auth/me').expect(401); + await agent + .get('/api/v1/auth/me') + .set('Authorization', `Bearer ${accessToken}`) .expect(200) - .expect('Hello World!'); + .expect(({ body }: { body: unknown }) => + expect((body as { email: string }).email).toBe('user@example.com'), + ); + + const firstCookie = registration.headers['set-cookie'][0]; + await agent.post('/api/v1/auth/refresh').expect(201); + await request(app.getHttpServer()) + .post('/api/v1/auth/refresh') + .set('Cookie', firstCookie) + .expect(401) + .expect('Content-Type', /application\/problem\+json/); + await agent.post('/api/v1/auth/logout').expect(204); + await agent.post('/api/v1/auth/logout').expect(204); + }); + + it('rejects duplicate registration and invalid credentials', async () => { + await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'user@example.com', password: 'a secure password' }) + .expect(201); + await request(app.getHttpServer()) + .post('/api/v1/auth/register') + .send({ email: 'USER@example.com', password: 'a secure password' }) + .expect(409) + .expect(({ body }: { body: unknown }) => + expect((body as Problem).code).toBe('EMAIL_ALREADY_REGISTERED'), + ); + await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ email: 'user@example.com', password: 'wrong password' }) + .expect(401) + .expect(({ body }: { body: unknown }) => + expect((body as Problem).code).toBe('INVALID_CREDENTIALS'), + ); }); }); diff --git a/test/e2e-env.cjs b/test/e2e-env.cjs index 210c778..dd4fc85 100644 --- a/test/e2e-env.cjs +++ b/test/e2e-env.cjs @@ -1,2 +1,3 @@ process.env.DATABASE_URL = 'file:/tmp/wealth-ledger-e2e.db'; process.env.FRONTEND_ORIGIN = 'http://localhost:5173'; +process.env.JWT_SECRET = 'e2e-secret-that-is-at-least-thirty-two-characters';