diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..09bcd33 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Server Configuration +PORT=3000 +NODE_ENV=development + +# Keycloak OpenID Connect Configuration +# The base URL of your Keycloak server (e.g., https://keycloak.example.com) +KEYCLOAK_AUTH_SERVER_URL=https://your-keycloak-instance.com/auth + +# The Keycloak realm name +KEYCLOAK_REALM=your-realm-name + +# The client ID registered in Keycloak for this application +KEYCLOAK_CLIENT_ID=friendolls-api + +# The client secret (required if the client is confidential) +# Leave empty if using a public client +KEYCLOAK_CLIENT_SECRET= + +# JWT Configuration +# The expected issuer of the JWT token (usually {KEYCLOAK_AUTH_SERVER_URL}/realms/{KEYCLOAK_REALM}) +JWT_ISSUER=https://your-keycloak-instance.com/auth/realms/your-realm-name + +# The expected audience in the JWT token (usually the client ID) +JWT_AUDIENCE=friendolls-api + +# JWKS URI for fetching public keys to verify JWT signatures +# Format: {KEYCLOAK_AUTH_SERVER_URL}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/certs +JWKS_URI=https://your-keycloak-instance.com/auth/realms/your-realm-name/protocol/openid-connect/certs diff --git a/eslint.config.mjs b/eslint.config.mjs index 4e9f827..a76ca21 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,7 +29,15 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn', - "prettier/prettier": ["error", { endOfLine: "auto" }], + 'prettier/prettier': ['error', { endOfLine: 'auto' }], + }, + }, + { + files: ['**/*.spec.ts'], + rules: { + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/unbound-method': 'off', + '@typescript-eslint/no-unsafe-call': 'off', }, }, ); diff --git a/package.json b/package.json index b0aa7d8..a9494bc 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,17 @@ }, "dependencies": { "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "dotenv": "^17.2.3", + "jwks-rsa": "^3.2.0", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8339a12..39e609a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,13 +10,40 @@ importers: dependencies: '@nestjs/common': specifier: ^11.0.1 - version: 11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.2 + version: 4.0.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.1 - version: 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': + specifier: ^11.0.5 + version: 11.0.5(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/platform-express': specifier: ^11.0.1 - version: 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) + version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) + '@nestjs/swagger': + specifier: ^11.2.3 + version: 11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.2 + version: 0.14.2 + dotenv: + specifier: ^17.2.3 + version: 17.2.3 + jwks-rsa: + specifier: ^3.2.0 + version: 3.2.0 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -38,7 +65,7 @@ importers: version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.0.1 - version: 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)) + version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-express@11.1.9) '@types/express': specifier: ^5.0.0 version: 5.0.5 @@ -637,6 +664,9 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -666,6 +696,12 @@ packages: class-validator: optional: true + '@nestjs/config@4.0.2': + resolution: {integrity: sha512-McMW6EXtpc8+CwTUwFdg6h7dYcBUpH5iUILCclAsa+MbCEvC9ZKu4dCHRlJqALuhjLw97pbQu62l4+wRwGeZqA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + '@nestjs/core@11.1.9': resolution: {integrity: sha512-a00B0BM4X+9z+t3UxJqIZlemIwCQdYoPKrMcM+ky4z3pkqqG1eTWexjs+YXpGObnLnjtMPVKWlcZHp3adDYvUw==} engines: {node: '>= 20'} @@ -684,6 +720,25 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/mapped-types@2.1.0': + resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/passport@11.0.5': + resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + passport: ^0.5.0 || ^0.6.0 || ^0.7.0 + '@nestjs/platform-express@11.1.9': resolution: {integrity: sha512-GVd3+0lO0mJq2m1kl9hDDnVrX3Nd4oH3oDfklz0pZEVEVS0KVSp63ufHq2Lu9cyPdSBuelJr9iPm2QQ1yX+Kmw==} peerDependencies: @@ -695,6 +750,23 @@ packages: peerDependencies: typescript: '>=4.8.2' + '@nestjs/swagger@11.2.3': + resolution: {integrity: sha512-a0xFfjeqk69uHIUpP8u0ryn4cKuHdra2Ug96L858i0N200Hxho+n3j+TlQXyOF4EstLSGjTfxI1Xb2E1lUxeNg==} + peerDependencies: + '@fastify/static': ^8.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/testing@11.1.9': resolution: {integrity: sha512-UFxerBDdb0RUNxQNj25pvkvNE7/vxKhXYWBt3QuwBFnYISzRIzhVlyIqLfoV5YI3zV0m0Nn4QAn1KM0zzwfEng==} peerDependencies: @@ -740,6 +812,9 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@sinclair/typebox@0.34.41': resolution: {integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==} @@ -801,9 +876,15 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@4.19.7': + resolution: {integrity: sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==} + '@types/express-serve-static-core@5.1.0': resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==} + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/express@5.0.5': resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==} @@ -825,12 +906,18 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@22.19.1': resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} @@ -858,6 +945,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -1249,6 +1339,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1312,6 +1405,12 @@ packages: cjs-module-lexer@2.1.1: resolution: {integrity: sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.2: + resolution: {integrity: sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -1470,6 +1569,18 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + dotenv-expand@12.0.1: + resolution: {integrity: sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==} + engines: {node: '>=12'} + + dotenv@16.4.7: + resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + engines: {node: '>=12'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1477,6 +1588,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2089,6 +2203,9 @@ packages: node-notifier: optional: true + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2131,6 +2248,20 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jwks-rsa@3.2.0: + resolution: {integrity: sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==} + engines: {node: '>=14'} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2142,6 +2273,12 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.12.29: + resolution: {integrity: sha512-P2aLrbeqHbmh8+9P35LXQfXOKc7XJ0ymUKl7tyeyQjdRNfzunXWxQXGc4yl3fUf28fqLRfPY+vIVvFXK7KEBTw==} + + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2161,12 +2298,36 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -2184,6 +2345,13 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru-memoizer@2.3.0: + resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -2387,6 +2555,17 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2414,6 +2593,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2706,6 +2888,9 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + swagger-ui-dist@5.30.2: + resolution: {integrity: sha512-HWCg1DTNE/Nmapt+0m2EPXFwNKNeKK4PwMjkwveN/zn1cV2Kxi9SURd+m0SpdcSgWEK/O64sf8bzXdtUhigtHA==} + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} @@ -2901,6 +3086,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -2908,6 +3097,10 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + validator@13.15.23: + resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -2982,6 +3175,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -3704,6 +3900,8 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@microsoft/tsdoc@0.16.0': {} + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.7.1 @@ -3737,7 +3935,7 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.1.0 iterare: 1.2.1 @@ -3746,12 +3944,23 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.2 transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/config@4.0.2(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 16.4.7 + dotenv-expand: 12.0.1 + lodash: 4.17.21 + rxjs: 7.8.2 + + '@nestjs/core@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -3761,12 +3970,25 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) + '@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) - '@nestjs/platform-express@11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)': + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.2 + + '@nestjs/passport@11.0.5(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + + '@nestjs/platform-express@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)': + dependencies: + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.5 express: 5.1.0 multer: 2.0.2 @@ -3786,13 +4008,28 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9))': + '@nestjs/swagger@11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.17.21 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.30.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.2 + + '@nestjs/testing@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-express@11.1.9)': + dependencies: + '@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) + '@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9) '@noble/hashes@1.8.0': {} @@ -3821,6 +4058,8 @@ snapshots: '@pkgr/core@0.2.9': {} + '@scarf/scarf@1.4.0': {} + '@sinclair/typebox@0.34.41': {} '@sinonjs/commons@3.0.1': @@ -3898,6 +4137,13 @@ snapshots: '@types/estree@1.0.8': {} + '@types/express-serve-static-core@4.19.7': + dependencies: + '@types/node': 22.19.1 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + '@types/express-serve-static-core@5.1.0': dependencies: '@types/node': 22.19.1 @@ -3905,6 +4151,13 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 1.2.1 + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.7 + '@types/qs': 6.14.0 + '@types/serve-static': 1.15.10 + '@types/express@5.0.5': dependencies: '@types/body-parser': 1.19.6 @@ -3930,10 +4183,17 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.19.1 + '@types/methods@1.1.4': {} '@types/mime@1.3.5': {} + '@types/ms@2.1.0': {} + '@types/node@22.19.1': dependencies: undici-types: 6.21.0 @@ -3971,6 +4231,8 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/validator@13.15.10': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -4409,6 +4671,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -4459,6 +4723,14 @@ snapshots: cjs-module-lexer@2.1.1: {} + class-transformer@0.5.1: {} + + class-validator@0.14.2: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.12.29 + validator: 13.15.23 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -4581,6 +4853,14 @@ snapshots: diff@4.0.2: {} + dotenv-expand@12.0.1: + dependencies: + dotenv: 16.4.7 + + dotenv@16.4.7: {} + + dotenv@17.2.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4589,6 +4869,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.259: {} @@ -5439,6 +5723,8 @@ snapshots: - supports-color - ts-node + jose@4.15.9: {} + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -5472,6 +5758,41 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + 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.1.1 + ms: 2.1.3 + semver: 7.7.3 + + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwks-rsa@3.2.0: + dependencies: + '@types/express': 4.17.25 + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3 + jose: 4.15.9 + limiter: 1.1.5 + lru-memoizer: 2.3.0 + transitivePeerDependencies: + - supports-color + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5483,6 +5804,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.29: {} + + limiter@1.1.5: {} + lines-and-columns@1.2.4: {} load-esm@1.0.3: {} @@ -5497,10 +5822,26 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.clonedeep@4.5.0: {} + + 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.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.17.21: {} log-symbols@4.1.0: @@ -5516,6 +5857,15 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru-memoizer@2.3.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 6.0.0 + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5697,6 +6047,19 @@ snapshots: parseurl@1.3.3: {} + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.2 + passport-strategy: 1.0.0 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -5717,6 +6080,8 @@ snapshots: path-type@4.0.0: {} + pause@0.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -6016,6 +6381,10 @@ snapshots: dependencies: has-flag: 4.0.0 + swagger-ui-dist@5.30.2: + dependencies: + '@scarf/scarf': 1.4.0 + symbol-observable@4.0.0: {} synckit@0.11.11: @@ -6214,6 +6583,8 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: @@ -6222,6 +6593,8 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + validator@13.15.23: {} + vary@1.1.2: {} walker@1.0.8: @@ -6312,6 +6685,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..2f14a3d --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - '@nestjs/core' diff --git a/src/app.module.ts b/src/app.module.ts index 8662803..18b88ec 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,9 +1,50 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { UsersModule } from './users/users.module'; +import { AuthModule } from './auth/auth.module'; +/** + * Validates required environment variables. + * Throws an error if any required variables are missing or invalid. + * Returns the validated config. + */ +function validateEnvironment(config: Record): Record { + const requiredVars = ['JWKS_URI', 'JWT_ISSUER', 'JWT_AUDIENCE']; + + const missingVars = requiredVars.filter((varName) => !config[varName]); + + if (missingVars.length > 0) { + throw new Error( + `Missing required environment variables: ${missingVars.join(', ')}`, + ); + } + + // Validate PORT if provided + if (config.PORT && isNaN(Number(config.PORT))) { + throw new Error('PORT must be a valid number'); + } + + return config; +} + +/** + * Root Application Module + * + * Imports and configures all feature modules and global configuration. + */ @Module({ - imports: [], + imports: [ + // Configure global environment variables with validation + ConfigModule.forRoot({ + isGlobal: true, // Make ConfigService available throughout the app + envFilePath: '.env', // Load from .env file + validate: validateEnvironment, // Validate required environment variables + }), + UsersModule, + AuthModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..633bc3c --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,51 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { PassportModule } from '@nestjs/passport'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { UsersModule } from '../users/users.module'; + +/** + * Authentication Module + * + * Provides Keycloak OpenID Connect authentication using JWT tokens. + * This module configures: + * - Passport for authentication strategies + * - JWT strategy for validating Keycloak tokens + * - Integration with UsersModule for user synchronization + * + * The module requires the following environment variables: + * - KEYCLOAK_AUTH_SERVER_URL: Base URL of Keycloak server + * - KEYCLOAK_REALM: Keycloak realm name + * - KEYCLOAK_CLIENT_ID: Client ID registered in Keycloak + * - JWT_ISSUER: Expected JWT issuer + * - JWT_AUDIENCE: Expected JWT audience + * - JWKS_URI: URI for fetching Keycloak's public keys + */ +@Module({ + imports: [ + // Import ConfigModule to access environment variables + ConfigModule, + + // Import PassportModule for authentication strategies + PassportModule.register({ defaultStrategy: 'jwt' }), + + // Import UsersModule to enable user synchronization (with forwardRef to avoid circular dependency) + forwardRef(() => UsersModule), + ], + providers: [ + // Register the JWT strategy for validating Keycloak tokens + JwtStrategy, + + // Register the auth service for business logic + AuthService, + ], + exports: [ + // Export AuthService so other modules can use it + AuthService, + + // Export PassportModule so guards can be used in other modules + PassportModule, + ], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..389cdad --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,365 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; +import { UsersService } from '../users/users.service'; +import type { AuthenticatedUser } from './decorators/current-user.decorator'; +import { User } from '../users/users.entity'; + +describe('AuthService', () => { + let service: AuthService; + + const mockFindByKeycloakSub = jest.fn(); + const mockUpdateFromToken = jest.fn(); + const mockCreateFromToken = jest.fn(); + + const mockUsersService = { + findByKeycloakSub: mockFindByKeycloakSub, + updateFromToken: mockUpdateFromToken, + createFromToken: mockCreateFromToken, + }; + + const mockAuthUser: AuthenticatedUser = { + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Test User', + username: 'testuser', + picture: 'https://example.com/avatar.jpg', + roles: ['user', 'premium'], + }; + + const mockUser: User = { + id: 'uuid-123', + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Test User', + username: 'testuser', + picture: 'https://example.com/avatar.jpg', + roles: ['user', 'premium'], + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + lastLoginAt: new Date('2024-01-01'), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { + provide: UsersService, + useValue: mockUsersService, + }, + ], + }).compile(); + + service = module.get(AuthService); + + // Reset mocks + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('syncUserFromToken', () => { + it('should create a new user if user does not exist', async () => { + mockFindByKeycloakSub.mockReturnValue(null); + mockCreateFromToken.mockReturnValue(mockUser); + + const result = await service.syncUserFromToken(mockAuthUser); + + expect(result).toEqual(mockUser); + expect(mockFindByKeycloakSub).toHaveBeenCalledWith('f:realm:user123'); + expect(mockCreateFromToken).toHaveBeenCalledWith({ + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Test User', + username: 'testuser', + picture: 'https://example.com/avatar.jpg', + roles: ['user', 'premium'], + }); + expect(mockUpdateFromToken).not.toHaveBeenCalled(); + }); + + it('should update existing user if user exists', async () => { + const updatedUser = { ...mockUser, lastLoginAt: new Date('2024-02-01') }; + mockFindByKeycloakSub.mockReturnValue(mockUser); + mockUpdateFromToken.mockReturnValue(updatedUser); + + const result = await service.syncUserFromToken(mockAuthUser); + + expect(result).toEqual(updatedUser); + expect(mockFindByKeycloakSub).toHaveBeenCalledWith('f:realm:user123'); + + expect(mockUpdateFromToken).toHaveBeenCalledWith( + 'f:realm:user123', + expect.objectContaining({ + email: 'test@example.com', + name: 'Test User', + username: 'testuser', + picture: 'https://example.com/avatar.jpg', + roles: ['user', 'premium'], + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + lastLoginAt: expect.any(Date), + }), + ); + expect(mockCreateFromToken).not.toHaveBeenCalled(); + }); + + it('should handle user with no email by using empty string', async () => { + const authUserNoEmail: AuthenticatedUser = { + keycloakSub: 'f:realm:user456', + name: 'No Email User', + }; + + mockFindByKeycloakSub.mockReturnValue(null); + mockCreateFromToken.mockReturnValue({ + ...mockUser, + email: '', + name: 'No Email User', + }); + + await service.syncUserFromToken(authUserNoEmail); + + expect(mockCreateFromToken).toHaveBeenCalledWith( + expect.objectContaining({ + email: '', + name: 'No Email User', + }), + ); + }); + + it('should handle user with no name by using username or fallback', async () => { + const authUserNoName: AuthenticatedUser = { + keycloakSub: 'f:realm:user789', + username: 'someusername', + }; + + mockFindByKeycloakSub.mockReturnValue(null); + mockCreateFromToken.mockReturnValue({ + ...mockUser, + name: 'someusername', + }); + + await service.syncUserFromToken(authUserNoName); + + expect(mockCreateFromToken).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'someusername', + }), + ); + }); + + it('should use "Unknown User" when no name or username is available', async () => { + const authUserMinimal: AuthenticatedUser = { + keycloakSub: 'f:realm:user000', + }; + + mockFindByKeycloakSub.mockReturnValue(null); + mockCreateFromToken.mockReturnValue({ + ...mockUser, + name: 'Unknown User', + }); + + await service.syncUserFromToken(authUserMinimal); + + expect(mockCreateFromToken).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Unknown User', + }), + ); + }); + + it('should handle empty keycloakSub gracefully', async () => { + const authUserEmptySub: AuthenticatedUser = { + keycloakSub: '', + email: 'empty@example.com', + name: 'Empty Sub User', + }; + + mockFindByKeycloakSub.mockReturnValue(null); + mockCreateFromToken.mockReturnValue({ + ...mockUser, + keycloakSub: '', + email: 'empty@example.com', + name: 'Empty Sub User', + }); + + const result = await service.syncUserFromToken(authUserEmptySub); + + expect(mockFindByKeycloakSub).toHaveBeenCalledWith(''); + expect(mockCreateFromToken).toHaveBeenCalledWith( + expect.objectContaining({ + keycloakSub: '', + email: 'empty@example.com', + name: 'Empty Sub User', + }), + ); + }); + + it('should handle malformed keycloakSub', async () => { + const authUserMalformed: AuthenticatedUser = { + keycloakSub: 'invalid-format', + email: 'malformed@example.com', + name: 'Malformed User', + }; + + mockFindByKeycloakSub.mockReturnValue(null); + mockCreateFromToken.mockReturnValue({ + ...mockUser, + keycloakSub: 'invalid-format', + email: 'malformed@example.com', + name: 'Malformed User', + }); + + const result = await service.syncUserFromToken(authUserMalformed); + + expect(mockFindByKeycloakSub).toHaveBeenCalledWith('invalid-format'); + expect(mockCreateFromToken).toHaveBeenCalledWith( + expect.objectContaining({ + keycloakSub: 'invalid-format', + email: 'malformed@example.com', + name: 'Malformed User', + }), + ); + }); + }); + + describe('hasRole', () => { + it('should return true if user has the required role', () => { + const result = service.hasRole(mockAuthUser, 'user'); + + expect(result).toBe(true); + }); + + it('should return false if user does not have the required role', () => { + const result = service.hasRole(mockAuthUser, 'admin'); + + expect(result).toBe(false); + }); + + it('should return false if user has no roles', () => { + const authUserNoRoles: AuthenticatedUser = { + keycloakSub: 'f:realm:noroles', + email: 'noroles@example.com', + name: 'No Roles User', + }; + + const result = service.hasRole(authUserNoRoles, 'user'); + + expect(result).toBe(false); + }); + + it('should return false if user roles is empty array', () => { + const authUserEmptyRoles: AuthenticatedUser = { + keycloakSub: 'f:realm:emptyroles', + email: 'empty@example.com', + name: 'Empty Roles User', + roles: [], + }; + + const result = service.hasRole(authUserEmptyRoles, 'user'); + + expect(result).toBe(false); + }); + }); + + describe('hasAnyRole', () => { + it('should return true if user has at least one of the required roles', () => { + const result = service.hasAnyRole(mockAuthUser, ['admin', 'premium']); + + expect(result).toBe(true); + }); + + it('should return false if user has none of the required roles', () => { + const result = service.hasAnyRole(mockAuthUser, ['admin', 'moderator']); + + expect(result).toBe(false); + }); + + it('should return false if user has no roles', () => { + const authUserNoRoles: AuthenticatedUser = { + keycloakSub: 'f:realm:noroles', + email: 'noroles@example.com', + name: 'No Roles User', + }; + + const result = service.hasAnyRole(authUserNoRoles, ['admin', 'user']); + + expect(result).toBe(false); + }); + + it('should return false if user roles is empty array', () => { + const authUserEmptyRoles: AuthenticatedUser = { + keycloakSub: 'f:realm:emptyroles', + email: 'empty@example.com', + name: 'Empty Roles User', + roles: [], + }; + + const result = service.hasAnyRole(authUserEmptyRoles, ['admin', 'user']); + + expect(result).toBe(false); + }); + + it('should handle multiple matching roles', () => { + const result = service.hasAnyRole(mockAuthUser, ['user', 'premium']); + + expect(result).toBe(true); + }); + }); + + describe('hasAllRoles', () => { + it('should return true if user has all of the required roles', () => { + const result = service.hasAllRoles(mockAuthUser, ['user', 'premium']); + + expect(result).toBe(true); + }); + + it('should return false if user has only some of the required roles', () => { + const result = service.hasAllRoles(mockAuthUser, [ + 'user', + 'premium', + 'admin', + ]); + + expect(result).toBe(false); + }); + + it('should return false if user has none of the required roles', () => { + const result = service.hasAllRoles(mockAuthUser, ['admin', 'moderator']); + + expect(result).toBe(false); + }); + + it('should return false if user has no roles', () => { + const authUserNoRoles: AuthenticatedUser = { + keycloakSub: 'f:realm:noroles', + email: 'noroles@example.com', + name: 'No Roles User', + }; + + const result = service.hasAllRoles(authUserNoRoles, ['user']); + + expect(result).toBe(false); + }); + + it('should return false if user roles is empty array', () => { + const authUserEmptyRoles: AuthenticatedUser = { + keycloakSub: 'f:realm:emptyroles', + email: 'empty@example.com', + name: 'Empty Roles User', + roles: [], + }; + + const result = service.hasAllRoles(authUserEmptyRoles, ['user']); + + expect(result).toBe(false); + }); + + it('should return true for single role check', () => { + const result = service.hasAllRoles(mockAuthUser, ['user']); + + expect(result).toBe(true); + }); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..aaa14bf --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,98 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { UsersService } from '../users/users.service'; +import type { AuthenticatedUser } from './decorators/current-user.decorator'; +import { User } from '../users/users.entity'; + +/** + * Authentication Service + * + * Handles authentication-related business logic including: + * - User synchronization from Keycloak tokens + * - Profile updates for authenticated users + */ +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name); + + constructor(private readonly usersService: UsersService) {} + + /** + * Synchronizes a user from Keycloak token to local database. + * Creates a new user if they don't exist, or updates their last login time. + * + * @param authenticatedUser - User data extracted from JWT token + * @returns The synchronized user entity + */ + async syncUserFromToken(authenticatedUser: AuthenticatedUser): Promise { + const { keycloakSub, email, name, username, picture, roles } = + authenticatedUser; + + // Try to find existing user by Keycloak subject + let user = this.usersService.findByKeycloakSub(keycloakSub); + + if (user) { + // User exists - update last login and sync profile data + this.logger.debug(`Syncing existing user: ${keycloakSub}`); + user = this.usersService.updateFromToken(keycloakSub, { + email, + name, + username, + picture, + roles, + lastLoginAt: new Date(), + }); + } else { + // New user - create from token data + this.logger.log(`Creating new user from token: ${keycloakSub}`); + user = this.usersService.createFromToken({ + keycloakSub, + email: email || '', + name: name || username || 'Unknown User', + username, + picture, + roles, + }); + } + + return Promise.resolve(user); + } + + /** + * Validates if a user has a specific role. + * + * @param user - The authenticated user + * @param requiredRole - The role to check for + * @returns True if the user has the role, false otherwise + */ + hasRole(user: AuthenticatedUser, requiredRole: string): boolean { + return user.roles?.includes(requiredRole) ?? false; + } + + /** + * Validates if a user has any of the specified roles. + * + * @param user - The authenticated user + * @param requiredRoles - Array of roles to check for + * @returns True if the user has at least one of the roles, false otherwise + */ + hasAnyRole(user: AuthenticatedUser, requiredRoles: string[]): boolean { + if (!user.roles || user.roles.length === 0) { + return false; + } + return requiredRoles.some((role) => user.roles!.includes(role)); + } + + /** + * Validates if a user has all of the specified roles. + * + * @param user - The authenticated user + * @param requiredRoles - Array of roles to check for + * @returns True if the user has all of the roles, false otherwise + */ + hasAllRoles(user: AuthenticatedUser, requiredRoles: string[]): boolean { + if (!user.roles || user.roles.length === 0) { + return false; + } + return requiredRoles.every((role) => user.roles!.includes(role)); + } +} diff --git a/src/auth/decorators/current-user.decorator.ts b/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..ff6db89 --- /dev/null +++ b/src/auth/decorators/current-user.decorator.ts @@ -0,0 +1,51 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +/** + * Interface representing the authenticated user from JWT token. + * This matches the object returned by JwtStrategy.validate() + */ +export interface AuthenticatedUser { + keycloakSub: string; + email?: string; + name?: string; + username?: string; + picture?: string; + roles?: string[]; +} + +/** + * CurrentUser Decorator + * + * Extracts the authenticated user from the request object. + * Must be used in conjunction with JwtAuthGuard. + * + * @example + * ```typescript + * @Get('me') + * @UseGuards(JwtAuthGuard) + * async getCurrentUser(@CurrentUser() user: AuthenticatedUser) { + * return user; + * } + * ``` + * + * @example + * Extract specific property: + * ```typescript + * @Get('profile') + * @UseGuards(JwtAuthGuard) + * async getProfile(@CurrentUser('keycloakSub') sub: string) { + * return { sub }; + * } + * ``` + */ +export const CurrentUser = createParamDecorator( + (data: keyof AuthenticatedUser | undefined, ctx: ExecutionContext) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const request = ctx.switchToHttp().getRequest(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const user = request.user as AuthenticatedUser; + + // If a specific property is requested, return only that property + return data ? user?.[data] : user; + }, +); diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..2993bdf --- /dev/null +++ b/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,78 @@ +import { ExecutionContext, Injectable, Logger } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Observable } from 'rxjs'; + +/** + * JWT Authentication Guard + * + * This guard protects routes by requiring a valid JWT token from Keycloak. + * It uses the JwtStrategy to validate the token and attach user info to the request. + * + * Usage: + * @UseGuards(JwtAuthGuard) + * async protectedRoute(@Request() req) { + * const user = req.user; // Contains validated user info from JWT + * } + */ +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + private readonly logger = new Logger(JwtAuthGuard.name); + + /** + * Determines if the request can proceed. + * Automatically validates the JWT token using JwtStrategy. + * + * @param context - The execution context + * @returns Boolean indicating if the request is authorized + */ + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + // Log the authentication attempt + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const request = context.switchToHttp().getRequest(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const authHeader = request.headers.authorization; + + if (!authHeader) { + this.logger.warn('Authentication attempt without Authorization header'); + } + + return super.canActivate(context); + } + + /** + * Handles errors during authentication. + * This method is called when the JWT validation fails. + * + * @param err - The error that occurred + */ + + handleRequest( + err: any, + user: any, + info: any, + context: ExecutionContext, + status?: any, + ): any { + if (err || !user) { + const infoMessage = + info && typeof info === 'object' && 'message' in info + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + String(info.message) + : ''; + const errMessage = + err && typeof err === 'object' && 'message' in err + ? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + String(err.message) + : ''; + this.logger.warn( + `Authentication failed: ${infoMessage || errMessage || 'Unknown error'}`, + ); + } + + // Let passport handle the error (will throw UnauthorizedException) + + return super.handleRequest(err, user, info, context, status); + } +} diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..4bc8052 --- /dev/null +++ b/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,127 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, ExtractJwt } from 'passport-jwt'; +import { passportJwtSecret } from 'jwks-rsa'; + +/** + * JWT payload interface representing the decoded token from Keycloak + */ +export interface JwtPayload { + sub: string; // Subject (user identifier in Keycloak) + email?: string; + name?: string; + preferred_username?: string; + picture?: string; + realm_access?: { + roles: string[]; + }; + resource_access?: { + [key: string]: { + roles: string[]; + }; + }; + iss: string; // Issuer + aud: string | string[]; // Audience + exp: number; // Expiration time + iat: number; // Issued at +} + +/** + * JWT Strategy for validating Keycloak-issued JWT tokens. + * This strategy validates tokens against Keycloak's public keys (JWKS) + * and extracts user information from the token payload. + */ +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + private readonly logger = new Logger(JwtStrategy.name); + + constructor(private configService: ConfigService) { + const jwksUri = configService.get('JWKS_URI'); + const issuer = configService.get('JWT_ISSUER'); + const audience = configService.get('JWT_AUDIENCE'); + + if (!jwksUri) { + throw new Error('JWKS_URI must be configured in environment variables'); + } + + if (!issuer) { + throw new Error('JWT_ISSUER must be configured in environment variables'); + } + + super({ + // Extract JWT from Authorization header as Bearer token + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + + // Use JWKS to fetch and cache Keycloak's public keys for signature verification + secretOrKeyProvider: passportJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri, + }), + + // Verify the issuer matches our Keycloak realm + issuer, + + // Verify the audience matches our client ID + audience, + + // Automatically reject expired tokens + ignoreExpiration: false, + + // Use RS256 algorithm (Keycloak's default) + algorithms: ['RS256'], + }); + + this.logger.log(`JWT Strategy initialized with issuer: ${issuer}`); + } + + /** + * Validates the JWT payload after signature verification. + * This method is called automatically by Passport after the token is verified. + * + * @param payload - The decoded JWT payload + * @returns The validated user object to be attached to the request + * @throws UnauthorizedException if the payload is invalid + */ + async validate(payload: JwtPayload): Promise<{ + keycloakSub: string; + email?: string; + name?: string; + username?: string; + picture?: string; + roles?: string[]; + }> { + if (!payload.sub) { + this.logger.warn('JWT token missing required "sub" claim'); + throw new UnauthorizedException('Invalid token: missing subject'); + } + + // Extract roles from Keycloak's realm_access and resource_access + const roles: string[] = []; + + if (payload.realm_access?.roles) { + roles.push(...payload.realm_access.roles); + } + + const clientId = this.configService.get('KEYCLOAK_CLIENT_ID'); + if (clientId && payload.resource_access?.[clientId]?.roles) { + roles.push(...payload.resource_access[clientId].roles); + } + + // Return user object that will be attached to request.user + const user = { + keycloakSub: payload.sub, + email: payload.email, + name: payload.name, + username: payload.preferred_username, + picture: payload.picture, + roles: roles.length > 0 ? roles : undefined, + }; + + this.logger.debug(`Validated token for user: ${payload.sub}`); + + return Promise.resolve(user); + } +} diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..2e35e72 --- /dev/null +++ b/src/common/filters/all-exceptions.filter.ts @@ -0,0 +1,82 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; + +/** + * Global Exception Filter + * + * Catches all exceptions thrown within the application and formats + * them into consistent HTTP responses. Provides proper error logging + * while avoiding exposure of sensitive internal information. + */ +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = new Logger(AllExceptionsFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status: HttpStatus; + let message: string; + let error: string; + + if (exception instanceof HttpException) { + // Handle known HTTP exceptions (e.g., NotFoundException, ForbiddenException) + status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + if (typeof exceptionResponse === 'string') { + message = exceptionResponse; + error = exception.name; + } else if ( + typeof exceptionResponse === 'object' && + exceptionResponse !== null && + 'message' in exceptionResponse && + 'error' in exceptionResponse + ) { + const responseObj = exceptionResponse as { + message: string; + error: string; + }; + message = responseObj.message; + error = responseObj.error; + } else { + message = exception.message; + error = exception.name; + } + } else { + // Handle unknown exceptions (programming errors, etc.) + status = HttpStatus.INTERNAL_SERVER_ERROR; + message = 'Internal server error'; + error = 'InternalServerError'; + + // Log the actual error for debugging (don't expose to client) + this.logger.error( + `Unhandled exception: ${exception instanceof Error ? exception.message : String(exception)}`, + exception instanceof Error ? exception.stack : undefined, + ); + } + + // Log the response being sent + this.logger.warn( + `HTTP ${status} Error: ${message} - ${request.method} ${request.url}`, + ); + + // Send consistent error response + response.status(status).json({ + statusCode: status, + message, + error, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} diff --git a/src/config/config.module.ts b/src/config/config.module.ts new file mode 100644 index 0000000..1948370 --- /dev/null +++ b/src/config/config.module.ts @@ -0,0 +1,4 @@ +import { Module } from '@nestjs/common'; + +@Module({}) +export class ConfigModule {} diff --git a/src/main.ts b/src/main.ts index f5c2729..97c68c3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,64 @@ import { NestFactory } from '@nestjs/core'; +import { ValidationPipe, Logger } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; +import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; async function bootstrap() { + const logger = new Logger('Bootstrap'); const app = await NestFactory.create(AppModule); - await app.listen(process.env.PORT ?? 3000); + + // Enable global exception filter for consistent error responses + app.useGlobalFilters(new AllExceptionsFilter()); + + // Enable global validation pipe for DTO validation + app.useGlobalPipes( + new ValidationPipe({ + // Strip properties that are not in the DTO + whitelist: true, + // Throw error if non-whitelisted properties are present + forbidNonWhitelisted: true, + // Automatically transform payloads to DTO instances + transform: true, + // Provide detailed error messages + disableErrorMessages: false, + }), + ); + + // Configure Swagger documentation + const config = new DocumentBuilder() + .setTitle('Friendolls API') + .setDescription( + 'API for managing users in Friendolls application.\n\n' + + 'Authentication is handled via Keycloak OpenID Connect.\n' + + 'Users must authenticate via Keycloak to obtain a JWT token.\n\n' + + 'Include the JWT token in the Authorization header as: `Bearer `', + ) + .setVersion('1.0') + .addBearerAuth( + { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + name: 'Authorization', + description: 'Enter JWT token obtained from Keycloak', + in: 'header', + }, + 'bearer', + ) + .addTag('users', 'User profile management endpoints') + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, document); + + const port = process.env.PORT ?? 3000; + await app.listen(port); + + logger.log(`Application is running on: http://localhost:${port}`); + logger.log( + `Swagger documentation available at: http://localhost:${port}/api`, + ); } void bootstrap(); diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..896a34d --- /dev/null +++ b/src/users/dto/update-user.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString, MinLength, MaxLength } from 'class-validator'; + +/** + * DTO for updating user profile. + * Only allows updating safe, user-controlled fields. + * Security-sensitive fields (keycloakSub, roles, email, etc.) are managed by Keycloak. + */ +export class UpdateUserDto { + /** + * User's display name + */ + @ApiProperty({ + description: "User's display name", + example: 'John Doe', + required: false, + minLength: 1, + maxLength: 100, + }) + @IsOptional() + @IsString() + @MinLength(1, { message: 'Name must not be empty' }) + @MaxLength(100, { message: 'Name must not exceed 100 characters' }) + name?: string; +} diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts new file mode 100644 index 0000000..0a4d31f --- /dev/null +++ b/src/users/users.controller.spec.ts @@ -0,0 +1,234 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, ForbiddenException } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { AuthService } from '../auth/auth.service'; +import { User } from './users.entity'; +import type { UpdateUserDto } from './dto/update-user.dto'; +import type { AuthenticatedUser } from '../auth/decorators/current-user.decorator'; + +describe('UsersController', () => { + let controller: UsersController; + + const mockFindOne = jest.fn(); + const mockUpdate = jest.fn(); + const mockDelete = jest.fn(); + const mockFindByKeycloakSub = jest.fn(); + + const mockSyncUserFromToken = jest.fn(); + + const mockUsersService = { + findOne: mockFindOne, + update: mockUpdate, + delete: mockDelete, + findByKeycloakSub: mockFindByKeycloakSub, + }; + + const mockAuthService = { + syncUserFromToken: mockSyncUserFromToken, + }; + + const mockAuthUser: AuthenticatedUser = { + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Test User', + username: 'testuser', + roles: ['user'], + }; + + const mockUser: User = { + id: 'uuid-123', + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Test User', + username: 'testuser', + roles: ['user'], + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + lastLoginAt: new Date('2024-01-01'), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + providers: [ + { + provide: UsersService, + useValue: mockUsersService, + }, + { + provide: AuthService, + useValue: mockAuthService, + }, + ], + }).compile(); + + controller = module.get(UsersController); + + // Reset mocks + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getCurrentUser', () => { + it('should return current user profile and sync from token', async () => { + mockSyncUserFromToken.mockResolvedValue(mockUser); + + const result = await controller.getCurrentUser(mockAuthUser); + + expect(result).toEqual(mockUser); + expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser); + }); + }); + + describe('updateCurrentUser', () => { + it('should update current user profile', async () => { + const updateDto: UpdateUserDto = { name: 'Updated Name' }; + const updatedUser: User = { ...mockUser, name: 'Updated Name' }; + + mockSyncUserFromToken.mockResolvedValue(mockUser); + mockUpdate.mockResolvedValue(updatedUser); + + const result = await controller.updateCurrentUser( + mockAuthUser, + updateDto, + ); + + expect(result).toEqual(updatedUser); + expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser); + expect(mockUpdate).toHaveBeenCalledWith( + mockUser.id, + updateDto, + mockAuthUser.keycloakSub, + ); + }); + }); + + describe('findOne', () => { + it('should return a user by id', async () => { + mockFindOne.mockReturnValue(mockUser); + + const result = await controller.findOne('uuid-123', mockAuthUser); + + expect(result).toEqual(mockUser); + expect(mockFindOne).toHaveBeenCalledWith('uuid-123'); + }); + + it('should throw NotFoundException if user not found', async () => { + mockFindOne.mockImplementation(() => { + throw new NotFoundException('User with ID non-existent not found'); + }); + + await expect( + controller.findOne('non-existent', mockAuthUser), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException if id is empty', async () => { + mockFindOne.mockImplementation(() => { + throw new NotFoundException('User with ID not found'); + }); + + await expect(controller.findOne('', mockAuthUser)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('update', () => { + it('should update a user by id', async () => { + const updateDto: UpdateUserDto = { name: 'Updated Name' }; + const updatedUser: User = { ...mockUser, name: 'Updated Name' }; + + mockUpdate.mockReturnValue(updatedUser); + + const result = await controller.update( + 'uuid-123', + updateDto, + mockAuthUser, + ); + + expect(result).toEqual(updatedUser); + expect(mockUpdate).toHaveBeenCalledWith( + 'uuid-123', + updateDto, + mockAuthUser.keycloakSub, + ); + }); + + it('should throw ForbiddenException when trying to update another user', async () => { + const updateDto: UpdateUserDto = { name: 'Updated Name' }; + + mockUpdate.mockImplementation(() => { + throw new ForbiddenException('You can only update your own profile'); + }); + + await expect( + controller.update('different-uuid', updateDto, mockAuthUser), + ).rejects.toThrow(ForbiddenException); + }); + + it('should throw NotFoundException if user not found', async () => { + const updateDto: UpdateUserDto = { name: 'Updated' }; + + mockUpdate.mockImplementation(() => { + throw new NotFoundException('User with ID non-existent not found'); + }); + + await expect( + controller.update('non-existent', updateDto, mockAuthUser), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('deleteCurrentUser', () => { + it('should delete current user account', async () => { + mockSyncUserFromToken.mockResolvedValue(mockUser); + mockDelete.mockReturnValue(undefined); + + await controller.deleteCurrentUser(mockAuthUser); + + expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser); + expect(mockDelete).toHaveBeenCalledWith( + mockUser.id, + mockAuthUser.keycloakSub, + ); + }); + }); + + describe('delete', () => { + it('should delete a user by id', () => { + mockDelete.mockReturnValue(undefined); + + controller.delete('uuid-123', mockAuthUser); + + expect(mockDelete).toHaveBeenCalledWith( + 'uuid-123', + mockAuthUser.keycloakSub, + ); + }); + + it('should throw ForbiddenException when trying to delete another user', () => { + mockDelete.mockImplementation(() => { + throw new ForbiddenException('You can only delete your own account'); + }); + + expect(() => controller.delete('different-uuid', mockAuthUser)).toThrow( + ForbiddenException, + ); + }); + + it('should throw NotFoundException if user not found', () => { + mockDelete.mockImplementation(() => { + throw new NotFoundException('User with ID non-existent not found'); + }); + + expect(() => controller.delete('non-existent', mockAuthUser)).toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts new file mode 100644 index 0000000..d3e542a --- /dev/null +++ b/src/users/users.controller.ts @@ -0,0 +1,267 @@ +import { + Controller, + Get, + Put, + Delete, + Param, + Body, + HttpCode, + UseGuards, + Logger, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiBearerAuth, + ApiUnauthorizedResponse, + ApiForbiddenResponse, +} from '@nestjs/swagger'; +import { UsersService } from './users.service'; +import { User } from './users.entity'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + CurrentUser, + type AuthenticatedUser, +} from '../auth/decorators/current-user.decorator'; +import { AuthService } from '../auth/auth.service'; + +/** + * Users Controller + * + * Handles user-related HTTP endpoints. + * All endpoints require authentication via Keycloak JWT token. + * + * Note: User creation is handled automatically during authentication flow. + * Users cannot be created directly via API - they must authenticate via Keycloak. + */ +@ApiTags('users') +@Controller('users') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class UsersController { + private readonly logger = new Logger(UsersController.name); + + constructor( + private readonly usersService: UsersService, + private readonly authService: AuthService, + ) {} + + /** + * Get current authenticated user's profile. + * This endpoint syncs the user from Keycloak token on each request. + */ + @Get('me') + @ApiOperation({ + summary: 'Get current user profile', + description: + 'Returns the authenticated user profile. Automatically syncs data from Keycloak token.', + }) + @ApiResponse({ + status: 200, + description: 'Current user profile', + type: User, + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async getCurrentUser( + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + this.logger.debug(`Get current user: ${authUser.keycloakSub}`); + + // Sync user from token (creates if doesn't exist, updates if exists) + const user = await this.authService.syncUserFromToken(authUser); + + return user; + } + + /** + * Update current authenticated user's profile. + */ + @Put('me') + @ApiOperation({ + summary: 'Update current user profile', + description: + 'Updates the authenticated user profile. Users can only update their own profile.', + }) + @ApiResponse({ + status: 200, + description: 'User profile updated successfully', + type: User, + }) + @ApiResponse({ + status: 400, + description: 'Invalid request data', + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async updateCurrentUser( + @CurrentUser() authUser: AuthenticatedUser, + @Body() updateUserDto: UpdateUserDto, + ): Promise { + this.logger.log(`Update current user: ${authUser.keycloakSub}`); + + // First ensure user exists in our system + const user = await this.authService.syncUserFromToken(authUser); + + // Update the user's profile + return Promise.resolve( + this.usersService.update(user.id, updateUserDto, authUser.keycloakSub), + ); + } + + /** + * Get a user by their ID. + * Currently allows any authenticated user to view other users. + * Consider adding additional authorization if needed. + */ + @Get(':id') + @ApiOperation({ + summary: 'Get a user by ID', + description: 'Retrieves a user profile by their internal ID.', + }) + @ApiParam({ + name: 'id', + description: 'User internal UUID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @ApiResponse({ + status: 200, + description: 'User found', + type: User, + }) + @ApiResponse({ + status: 404, + description: 'User not found', + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async findOne( + @Param('id') id: string, + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + this.logger.debug( + `Get user by ID: ${id} (requested by ${authUser.keycloakSub})`, + ); + return Promise.resolve(this.usersService.findOne(id)); + } + + /** + * Update a user by their ID. + * Users can only update their own profile (enforced by service layer). + */ + @Put(':id') + @ApiOperation({ + summary: 'Update a user by ID', + description: + 'Updates a user profile. Users can only update their own profile.', + }) + @ApiParam({ + name: 'id', + description: 'User internal UUID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @ApiResponse({ + status: 200, + description: 'User updated successfully', + type: User, + }) + @ApiResponse({ + status: 400, + description: 'Invalid request data', + }) + @ApiResponse({ + status: 404, + description: 'User not found', + }) + @ApiForbiddenResponse({ + description: 'Cannot update another user profile', + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async update( + @Param('id') id: string, + @Body() updateUserDto: UpdateUserDto, + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + this.logger.log(`Update user ${id} (requested by ${authUser.keycloakSub})`); + return Promise.resolve( + this.usersService.update(id, updateUserDto, authUser.keycloakSub), + ); + } + + /** + * Delete current authenticated user's account. + * Note: This only deletes the local user record. + * The user still exists in Keycloak and can re-authenticate. + */ + @Delete('me') + @ApiOperation({ + summary: 'Delete current user account', + description: + 'Deletes the authenticated user account. Only removes local data; user still exists in Keycloak.', + }) + @ApiResponse({ + status: 204, + description: 'User account deleted successfully', + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + @HttpCode(204) + async deleteCurrentUser( + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + this.logger.log(`Delete current user: ${authUser.keycloakSub}`); + + // First ensure user exists in our system + const user = await this.authService.syncUserFromToken(authUser); + + // Delete the user's account + this.usersService.delete(user.id, authUser.keycloakSub); + } + + /** + * Delete a user by their ID. + * Users can only delete their own account (enforced by service layer). + */ + @Delete(':id') + @ApiOperation({ + summary: 'Delete a user by ID', + description: + 'Deletes a user account. Users can only delete their own account. Only removes local data; user still exists in Keycloak.', + }) + @ApiParam({ + name: 'id', + description: 'User internal UUID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @ApiResponse({ + status: 204, + description: 'User deleted successfully', + }) + @ApiResponse({ + status: 404, + description: 'User not found', + }) + @ApiForbiddenResponse({ + description: 'Cannot delete another user account', + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + @HttpCode(204) + delete( + @Param('id') id: string, + @CurrentUser() authUser: AuthenticatedUser, + ): void { + this.logger.log(`Delete user ${id} (requested by ${authUser.keycloakSub})`); + this.usersService.delete(id, authUser.keycloakSub); + } +} diff --git a/src/users/users.entity.ts b/src/users/users.entity.ts new file mode 100644 index 0000000..b023544 --- /dev/null +++ b/src/users/users.entity.ts @@ -0,0 +1,102 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * User entity representing a user in the system. + * Users are synced from Keycloak via OIDC authentication. + */ +export class User { + /** + * Internal unique identifier (UUID) + */ + @ApiProperty({ + description: 'Internal unique identifier', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + id: string; + + /** + * Keycloak subject identifier (unique per user in Keycloak) + */ + @ApiProperty({ + description: 'Keycloak subject identifier from the JWT token', + example: 'f:a1b2c3d4-e5f6-7890-abcd-ef1234567890:johndoe', + }) + keycloakSub: string; + + /** + * User's display name + */ + @ApiProperty({ + description: "User's display name", + example: 'John Doe', + }) + name: string; + + /** + * User's email address + */ + @ApiProperty({ + description: "User's email address", + example: 'john.doe@example.com', + }) + email: string; + + /** + * User's preferred username from Keycloak + */ + @ApiProperty({ + description: "User's preferred username from Keycloak", + example: 'johndoe', + required: false, + }) + username?: string; + + /** + * URL to user's profile picture + */ + @ApiProperty({ + description: "URL to user's profile picture", + example: 'https://example.com/avatars/johndoe.jpg', + required: false, + }) + picture?: string; + + /** + * User's roles from Keycloak + */ + @ApiProperty({ + description: "User's roles from Keycloak", + example: ['user', 'premium'], + type: [String], + required: false, + }) + roles?: string[]; + + /** + * Timestamp when the user was first created in the system + */ + @ApiProperty({ + description: 'Timestamp when the user was first created', + example: '2024-01-15T10:30:00.000Z', + }) + createdAt: Date; + + /** + * Timestamp when the user profile was last updated + */ + @ApiProperty({ + description: 'Timestamp when the user was last updated', + example: '2024-01-20T14:45:00.000Z', + }) + updatedAt: Date; + + /** + * Timestamp of last login + */ + @ApiProperty({ + description: 'Timestamp of last login', + example: '2024-01-20T14:45:00.000Z', + required: false, + }) + lastLoginAt?: Date; +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts new file mode 100644 index 0000000..914b70d --- /dev/null +++ b/src/users/users.module.ts @@ -0,0 +1,21 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { UsersController } from './users.controller'; +import { AuthModule } from '../auth/auth.module'; + +/** + * Users Module + * + * Manages user-related functionality including user profile management + * and synchronization with Keycloak OIDC. + * + * The module exports UsersService to allow other modules (like AuthModule) + * to access user data and perform synchronization. + */ +@Module({ + imports: [forwardRef(() => AuthModule)], + providers: [UsersService], + controllers: [UsersController], + exports: [UsersService], // Export so AuthModule can use it +}) +export class UsersModule {} diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts new file mode 100644 index 0000000..5abca93 --- /dev/null +++ b/src/users/users.service.spec.ts @@ -0,0 +1,287 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, ForbiddenException } from '@nestjs/common'; +import { UsersService } from './users.service'; +import type { UpdateUserDto } from './dto/update-user.dto'; + +describe('UsersService', () => { + let service: UsersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UsersService], + }).compile(); + + service = module.get(UsersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('createFromToken', () => { + it('should create a new user from Keycloak token data', () => { + const tokenData = { + keycloakSub: 'f:realm:user123', + email: 'john@example.com', + name: 'John Doe', + username: 'johndoe', + picture: 'https://example.com/avatar.jpg', + roles: ['user', 'premium'], + }; + + const user = service.createFromToken(tokenData); + + expect(user).toBeDefined(); + expect(user.id).toBeDefined(); + expect(typeof user.id).toBe('string'); + expect(user.keycloakSub).toBe('f:realm:user123'); + expect(user.email).toBe('john@example.com'); + expect(user.name).toBe('John Doe'); + expect(user.username).toBe('johndoe'); + expect(user.picture).toBe('https://example.com/avatar.jpg'); + expect(user.roles).toEqual(['user', 'premium']); + expect(user.createdAt).toBeInstanceOf(Date); + expect(user.updatedAt).toBeInstanceOf(Date); + expect(user.lastLoginAt).toBeInstanceOf(Date); + }); + + it('should return existing user if keycloakSub already exists', () => { + const tokenData = { + keycloakSub: 'f:realm:user123', + email: 'john@example.com', + name: 'John Doe', + }; + + const user1 = service.createFromToken(tokenData); + const user2 = service.createFromToken(tokenData); + + expect(user1.id).toBe(user2.id); + expect(user1.keycloakSub).toBe(user2.keycloakSub); + }); + }); + + describe('findByKeycloakSub', () => { + it('should return the user if found by keycloakSub', () => { + const createdUser = service.createFromToken({ + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Test User', + }); + + const user = service.findByKeycloakSub('f:realm:user123'); + + expect(user).toBeDefined(); + expect(user?.id).toBe(createdUser.id); + expect(user?.keycloakSub).toBe('f:realm:user123'); + }); + + it('should return null if user not found by keycloakSub', () => { + const user = service.findByKeycloakSub('non-existent-sub'); + expect(user).toBeNull(); + }); + }); + + describe('findOne', () => { + it('should return the user if found by ID', () => { + const createdUser = service.createFromToken({ + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Test User', + }); + + const user = service.findOne(createdUser.id); + + expect(user).toEqual(createdUser); + }); + + it('should throw NotFoundException if user not found by ID', () => { + expect(() => service.findOne('non-existent-id')).toThrow( + NotFoundException, + ); + expect(() => service.findOne('non-existent-id')).toThrow( + 'User with ID non-existent-id not found', + ); + }); + }); + + describe('updateFromToken', () => { + it('should update user data from token and set lastLoginAt', async () => { + const createdUser = service.createFromToken({ + keycloakSub: 'f:realm:user123', + email: 'old@example.com', + name: 'Old Name', + }); + + const originalUpdatedAt = createdUser.updatedAt; + + // Wait a bit to ensure timestamp difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + const updatedUser = service.updateFromToken('f:realm:user123', { + email: 'new@example.com', + name: 'New Name', + username: 'newusername', + roles: ['admin'], + lastLoginAt: new Date(), + }); + + expect(updatedUser.id).toBe(createdUser.id); + expect(updatedUser.email).toBe('new@example.com'); + expect(updatedUser.name).toBe('New Name'); + expect(updatedUser.username).toBe('newusername'); + expect(updatedUser.roles).toEqual(['admin']); + expect(updatedUser.lastLoginAt).toBeDefined(); + expect(updatedUser.updatedAt.getTime()).toBeGreaterThan( + originalUpdatedAt.getTime(), + ); + }); + + it('should throw NotFoundException if user not found', () => { + expect(() => + service.updateFromToken('non-existent-sub', { + email: 'test@example.com', + }), + ).toThrow(NotFoundException); + }); + + it('should only update provided fields', () => { + const createdUser = service.createFromToken({ + keycloakSub: 'f:realm:user123', + email: 'original@example.com', + name: 'Original Name', + username: 'original', + }); + + const updatedUser = service.updateFromToken('f:realm:user123', { + email: 'updated@example.com', + // name and username not provided + }); + + expect(updatedUser.email).toBe('updated@example.com'); + expect(updatedUser.name).toBe('Original Name'); // unchanged + expect(updatedUser.username).toBe('original'); // unchanged + }); + }); + + describe('update', () => { + it('should update user profile when user updates their own profile', () => { + const createdUser = service.createFromToken({ + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Old Name', + }); + + const updateUserDto: UpdateUserDto = { + name: 'New Name', + }; + + const updatedUser = service.update( + createdUser.id, + updateUserDto, + 'f:realm:user123', + ); + + expect(updatedUser.id).toBe(createdUser.id); + expect(updatedUser.name).toBe('New Name'); + }); + + it('should throw ForbiddenException when user tries to update another user', () => { + const createdUser = service.createFromToken({ + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Test User', + }); + + const updateUserDto: UpdateUserDto = { name: 'New Name' }; + + expect(() => + service.update(createdUser.id, updateUserDto, 'f:realm:differentuser'), + ).toThrow(ForbiddenException); + + expect(() => + service.update(createdUser.id, updateUserDto, 'f:realm:differentuser'), + ).toThrow('You can only update your own profile'); + }); + + it('should throw NotFoundException if user not found', () => { + const updateUserDto: UpdateUserDto = { name: 'New Name' }; + + expect(() => + service.update('non-existent-id', updateUserDto, 'f:realm:user123'), + ).toThrow(NotFoundException); + }); + + it('should update user profile with empty name', () => { + const createdUser = service.createFromToken({ + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Old Name', + }); + + const updateUserDto: UpdateUserDto = { name: '' }; + + const updatedUser = service.update( + createdUser.id, + updateUserDto, + 'f:realm:user123', + ); + + expect(updatedUser.name).toBe(''); + }); + + it('should update user profile with very long name', () => { + const createdUser = service.createFromToken({ + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Old Name', + }); + + const longName = 'A'.repeat(200); // Exceeds typical limits + const updateUserDto: UpdateUserDto = { name: longName }; + + const updatedUser = service.update( + createdUser.id, + updateUserDto, + 'f:realm:user123', + ); + + expect(updatedUser.name).toBe(longName); + }); + }); + + describe('delete', () => { + it('should delete user when user deletes their own account', () => { + const createdUser = service.createFromToken({ + keycloakSub: 'f:realm:user123', + email: 'delete@example.com', + name: 'To Delete', + }); + + service.delete(createdUser.id, 'f:realm:user123'); + + expect(() => service.findOne(createdUser.id)).toThrow(NotFoundException); + }); + + it('should throw ForbiddenException when user tries to delete another user', () => { + const createdUser = service.createFromToken({ + keycloakSub: 'f:realm:user123', + email: 'test@example.com', + name: 'Test User', + }); + + expect(() => + service.delete(createdUser.id, 'f:realm:differentuser'), + ).toThrow(ForbiddenException); + + expect(() => + service.delete(createdUser.id, 'f:realm:differentuser'), + ).toThrow('You can only delete your own account'); + }); + + it('should throw NotFoundException if user not found', () => { + expect(() => + service.delete('non-existent-id', 'f:realm:user123'), + ).toThrow(NotFoundException); + }); + }); +}); diff --git a/src/users/users.service.ts b/src/users/users.service.ts new file mode 100644 index 0000000..67119a7 --- /dev/null +++ b/src/users/users.service.ts @@ -0,0 +1,218 @@ +import { + Injectable, + NotFoundException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { User } from './users.entity'; +import type { UpdateUserDto } from './dto/update-user.dto'; + +/** + * Interface for creating a user from Keycloak token + */ +export interface CreateUserFromTokenDto { + keycloakSub: string; + email: string; + name: string; + username?: string; + picture?: string; + roles?: string[]; +} + +/** + * Interface for updating a user from Keycloak token + */ +export interface UpdateUserFromTokenDto { + email?: string; + name?: string; + username?: string; + picture?: string; + roles?: string[]; + lastLoginAt?: Date; +} + +/** + * Users Service + * + * Manages user data synchronized from Keycloak OIDC. + * Users are created automatically when they first authenticate via Keycloak. + * Direct user creation is not allowed - users must authenticate via Keycloak first. + */ +@Injectable() +export class UsersService { + private readonly logger = new Logger(UsersService.name); + private users: User[] = []; + + /** + * Creates a new user from Keycloak token data. + * This method is called automatically during authentication flow. + * + * @param createDto - User data extracted from Keycloak JWT token + * @returns The newly created user + */ + createFromToken(createDto: CreateUserFromTokenDto): User { + const existingUser = this.users.find( + (u) => u.keycloakSub === createDto.keycloakSub, + ); + + if (existingUser) { + this.logger.warn( + `Attempted to create duplicate user with keycloakSub: ${createDto.keycloakSub}`, + ); + return existingUser; + } + + const newUser = new User(); + newUser.id = randomUUID(); + newUser.keycloakSub = createDto.keycloakSub; + newUser.email = createDto.email; + newUser.name = createDto.name; + newUser.username = createDto.username; + newUser.picture = createDto.picture; + newUser.roles = createDto.roles; + newUser.createdAt = new Date(); + newUser.updatedAt = new Date(); + newUser.lastLoginAt = new Date(); + + this.users.push(newUser); + + this.logger.log(`Created new user: ${newUser.id} (${newUser.keycloakSub})`); + + return newUser; + } + + /** + * Finds a user by their Keycloak subject identifier. + * + * @param keycloakSub - The Keycloak subject (sub claim from JWT) + * @returns The user if found, null otherwise + */ + findByKeycloakSub(keycloakSub: string): User | null { + const user = this.users.find((u) => u.keycloakSub === keycloakSub); + return user || null; + } + + /** + * Finds a user by their internal ID. + * + * @param id - The user's internal UUID + * @returns The user entity + * @throws NotFoundException if the user is not found + */ + findOne(id: string): User { + const user = this.users.find((u) => u.id === id); + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + return user; + } + + /** + * Updates a user's profile from Keycloak token data. + * This syncs the user's data from Keycloak during authentication. + * + * @param keycloakSub - The Keycloak subject identifier + * @param updateDto - Updated user data from token + * @returns The updated user + * @throws NotFoundException if the user is not found + */ + updateFromToken( + keycloakSub: string, + updateDto: UpdateUserFromTokenDto, + ): User { + const user = this.findByKeycloakSub(keycloakSub); + if (!user) { + throw new NotFoundException( + `User with keycloakSub ${keycloakSub} not found`, + ); + } + + // Update user properties from token + if (updateDto.email !== undefined) user.email = updateDto.email; + if (updateDto.name !== undefined) user.name = updateDto.name; + if (updateDto.username !== undefined) user.username = updateDto.username; + if (updateDto.picture !== undefined) user.picture = updateDto.picture; + if (updateDto.roles !== undefined) user.roles = updateDto.roles; + if (updateDto.lastLoginAt !== undefined) + user.lastLoginAt = updateDto.lastLoginAt; + + user.updatedAt = new Date(); + + this.logger.debug(`Synced user from token: ${user.id} (${keycloakSub})`); + + return user; + } + + /** + * Updates a user's profile. + * Users can only update their own profile (enforced by controller). + * + * @param id - The user's internal ID + * @param updateUserDto - The fields to update + * @param requestingUserKeycloakSub - The Keycloak sub of the requesting user + * @returns The updated user + * @throws NotFoundException if the user is not found + * @throws ForbiddenException if the user tries to update someone else's profile + */ + update( + id: string, + updateUserDto: UpdateUserDto, + requestingUserKeycloakSub: string, + ): User { + const user = this.findOne(id); + + // Verify the user is updating their own profile + if (user.keycloakSub !== requestingUserKeycloakSub) { + this.logger.warn( + `User ${requestingUserKeycloakSub} attempted to update user ${id}`, + ); + throw new ForbiddenException('You can only update your own profile'); + } + + // Only allow updating specific fields via the public API + // Security-sensitive fields (keycloakSub, roles, etc.) cannot be updated + if (updateUserDto.name !== undefined) { + user.name = updateUserDto.name; + } + + user.updatedAt = new Date(); + + this.logger.log(`User ${id} updated their profile`); + + return user; + } + + /** + * Deletes a user from the system. + * Note: This only removes the local user record. + * The user still exists in Keycloak and can re-authenticate. + * + * @param id - The user's internal ID + * @param requestingUserKeycloakSub - The Keycloak sub of the requesting user + * @throws NotFoundException if the user is not found + * @throws ForbiddenException if the user tries to delete someone else's account + */ + delete(id: string, requestingUserKeycloakSub: string): void { + const user = this.findOne(id); + + // Verify the user is deleting their own account + if (user.keycloakSub !== requestingUserKeycloakSub) { + this.logger.warn( + `User ${requestingUserKeycloakSub} attempted to delete user ${id}`, + ); + throw new ForbiddenException('You can only delete your own account'); + } + + const index = this.users.findIndex((u) => u.id === id); + if (index === -1) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + this.users.splice(index, 1); + + this.logger.log( + `User ${id} deleted their account (Keycloak: ${requestingUserKeycloakSub})`, + ); + } +}