init user system with keycloak

This commit is contained in:
2025-11-23 00:17:27 +08:00
parent f1d3ead212
commit d88c2057c0
22 changed files with 2546 additions and 18 deletions

28
.env.example Normal file
View File

@@ -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

View File

@@ -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',
},
},
);

View File

@@ -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"
},

405
pnpm-lock.yaml generated
View File

@@ -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:

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- '@nestjs/core'

View File

@@ -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<string, any>): Record<string, any> {
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],
})

51
src/auth/auth.module.ts Normal file
View File

@@ -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 {}

View File

@@ -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>(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);
});
});
});

98
src/auth/auth.service.ts Normal file
View File

@@ -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<User> {
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));
}
}

View File

@@ -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;
},
);

View File

@@ -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<boolean> | Observable<boolean> {
// 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);
}
}

View File

@@ -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<string>('JWKS_URI');
const issuer = configService.get<string>('JWT_ISSUER');
const audience = configService.get<string>('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<string>('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);
}
}

View File

@@ -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<Response>();
const request = ctx.getRequest<Request>();
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,
});
}
}

View File

@@ -0,0 +1,4 @@
import { Module } from '@nestjs/common';
@Module({})
export class ConfigModule {}

View File

@@ -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 <token>`',
)
.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();

View File

@@ -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;
}

View File

@@ -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>(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,
);
});
});
});

View File

@@ -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<User> {
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<User> {
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<User> {
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<User> {
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<void> {
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);
}
}

102
src/users/users.entity.ts Normal file
View File

@@ -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;
}

21
src/users/users.module.ts Normal file
View File

@@ -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 {}

View File

@@ -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>(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);
});
});
});

218
src/users/users.service.ts Normal file
View File

@@ -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})`,
);
}
}