native auth
This commit is contained in:
17
.env.example
17
.env.example
@@ -11,17 +11,10 @@ REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# JWT Configuration
|
||||
# Keycloak realm URL (no trailing slash). Example: https://keycloak.example.com/realms/friendolls
|
||||
JWT_ISSUER=https://your-keycloak-instance.com/auth/realms/your-realm-name
|
||||
|
||||
# The expected audience in the JWT token (usually the client ID for this API)
|
||||
JWT_SECRET=replace-with-strong-random-secret
|
||||
JWT_ISSUER=friendolls
|
||||
JWT_AUDIENCE=friendolls-api
|
||||
JWT_EXPIRES_IN_SECONDS=3600
|
||||
|
||||
# Keycloak client used for access tokens
|
||||
KEYCLOAK_CLIENT_ID=friendolls-api
|
||||
# Optional: client secret for revoking refresh tokens (omit for public clients)
|
||||
KEYCLOAK_CLIENT_SECRET=
|
||||
|
||||
# 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
|
||||
# Temporary migration flow (remove after migration)
|
||||
ALLOW_LEGACY_PASSWORD=true
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"ioredis": "^5.8.2",
|
||||
"axios": "^1.7.9",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.2.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.16.3",
|
||||
|
||||
99
pnpm-lock.yaml
generated
99
pnpm-lock.yaml
generated
@@ -19,7 +19,7 @@ importers:
|
||||
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)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/event-emitter':
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1(@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)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||
version: 3.0.1(@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/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)
|
||||
@@ -31,10 +31,10 @@ importers:
|
||||
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/websockets@11.1.9)(rxjs@7.8.2)
|
||||
'@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(@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)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
|
||||
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)
|
||||
'@nestjs/throttler':
|
||||
specifier: ^6.5.0
|
||||
version: 6.5.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)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)
|
||||
version: 6.5.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)(reflect-metadata@0.2.2)
|
||||
'@nestjs/websockets':
|
||||
specifier: ^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-socket.io@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -50,6 +50,9 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.7.9
|
||||
version: 1.13.2
|
||||
bcryptjs:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.3
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
@@ -65,9 +68,6 @@ importers:
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
jwks-rsa:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
passport:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
@@ -101,7 +101,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(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)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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))
|
||||
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
|
||||
@@ -1071,15 +1071,9 @@ 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==}
|
||||
|
||||
@@ -1539,6 +1533,10 @@ packages:
|
||||
resolution: {integrity: sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==}
|
||||
hasBin: true
|
||||
|
||||
bcryptjs@3.0.3:
|
||||
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
||||
hasBin: true
|
||||
|
||||
bl@4.1.0:
|
||||
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
|
||||
|
||||
@@ -2548,9 +2546,6 @@ packages:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@4.15.9:
|
||||
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -2600,10 +2595,6 @@ packages:
|
||||
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==}
|
||||
|
||||
@@ -2625,9 +2616,6 @@ packages:
|
||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
limiter@1.1.5:
|
||||
resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
@@ -2647,9 +2635,6 @@ 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.defaults@4.2.0:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
|
||||
@@ -2703,17 +2688,10 @@ 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-cache@7.18.3:
|
||||
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
lru-memoizer@2.3.0:
|
||||
resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==}
|
||||
|
||||
lru.min@1.1.3:
|
||||
resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
|
||||
@@ -3749,9 +3727,6 @@ 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'}
|
||||
@@ -4586,7 +4561,7 @@ snapshots:
|
||||
'@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/websockets': 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-socket.io@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
|
||||
'@nestjs/event-emitter@3.0.1(@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)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
|
||||
'@nestjs/event-emitter@3.0.1(@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)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -4640,7 +4615,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@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(@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)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)':
|
||||
'@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:
|
||||
'@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)
|
||||
@@ -4655,7 +4630,7 @@ snapshots:
|
||||
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/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)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@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/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)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -4663,7 +4638,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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/throttler@6.5.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)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)':
|
||||
'@nestjs/throttler@6.5.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)(reflect-metadata@0.2.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)
|
||||
'@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)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -4891,13 +4866,6 @@ 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
|
||||
@@ -4905,13 +4873,6 @@ 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
|
||||
@@ -5423,6 +5384,8 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.8.30: {}
|
||||
|
||||
bcryptjs@3.0.3: {}
|
||||
|
||||
bl@4.1.0:
|
||||
dependencies:
|
||||
buffer: 5.7.1
|
||||
@@ -6648,8 +6611,6 @@ snapshots:
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@4.15.9: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@3.14.2:
|
||||
@@ -6702,17 +6663,6 @@ snapshots:
|
||||
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
|
||||
@@ -6733,8 +6683,6 @@ snapshots:
|
||||
|
||||
lilconfig@2.1.0: {}
|
||||
|
||||
limiter@1.1.5: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
load-esm@1.0.3: {}
|
||||
@@ -6749,8 +6697,6 @@ snapshots:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
|
||||
lodash.clonedeep@4.5.0: {}
|
||||
|
||||
lodash.defaults@4.2.0: {}
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
@@ -6790,17 +6736,8 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lru-cache@6.0.0:
|
||||
dependencies:
|
||||
yallist: 4.0.0
|
||||
|
||||
lru-cache@7.18.3: {}
|
||||
|
||||
lru-memoizer@2.3.0:
|
||||
dependencies:
|
||||
lodash.clonedeep: 4.5.0
|
||||
lru-cache: 6.0.0
|
||||
|
||||
lru.min@1.1.3: {}
|
||||
|
||||
magic-string@0.30.17:
|
||||
@@ -7819,8 +7756,6 @@ snapshots:
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yallist@4.0.0: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@17.7.2:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Add local auth fields and make keycloak sub optional
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "password_hash" TEXT;
|
||||
|
||||
ALTER TABLE "users"
|
||||
ALTER COLUMN "keycloak_sub" DROP NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes WHERE indexname = 'users_email_key'
|
||||
) THEN
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -9,20 +9,20 @@ datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
/// User model representing authenticated users from Keycloak OIDC
|
||||
/// User model representing authenticated users from local auth
|
||||
model User {
|
||||
/// Internal unique identifier (UUID)
|
||||
id String @id @default(uuid())
|
||||
|
||||
/// Keycloak subject identifier (unique per user in Keycloak)
|
||||
/// This is the 'sub' claim from the JWT token
|
||||
keycloakSub String @unique @map("keycloak_sub")
|
||||
/// Keycloak subject identifier (legacy for migration)
|
||||
/// This is the 'sub' claim from the old JWT token
|
||||
keycloakSub String? @unique @map("keycloak_sub")
|
||||
|
||||
/// User's display name
|
||||
name String
|
||||
|
||||
/// User's email address
|
||||
email String
|
||||
email String @unique
|
||||
|
||||
/// User's preferred username from Keycloak
|
||||
username String?
|
||||
@@ -33,6 +33,9 @@ model User {
|
||||
/// User's roles from Keycloak (stored as JSON array)
|
||||
roles String[]
|
||||
|
||||
/// Password hash for local authentication
|
||||
passwordHash String? @map("password_hash")
|
||||
|
||||
/// Timestamp when the user was first created in the system
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
|
||||
@@ -18,12 +18,7 @@ import { DollsModule } from './dolls/dolls.module';
|
||||
* Returns the validated config.
|
||||
*/
|
||||
function validateEnvironment(config: Record<string, any>): Record<string, any> {
|
||||
const requiredVars = [
|
||||
'JWKS_URI',
|
||||
'JWT_ISSUER',
|
||||
'JWT_AUDIENCE',
|
||||
'DATABASE_URL',
|
||||
];
|
||||
const requiredVars = ['JWT_SECRET', 'DATABASE_URL'];
|
||||
|
||||
const missingVars = requiredVars.filter((varName) => !config[varName]);
|
||||
|
||||
|
||||
103
src/auth/auth.controller.ts
Normal file
103
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
Post,
|
||||
UseGuards,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
ApiBadRequestResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginRequestDto } from './dto/login-request.dto';
|
||||
import { RegisterRequestDto } from './dto/register-request.dto';
|
||||
import { LoginResponseDto } from './dto/login-response.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from './decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
@ApiResponse({ status: 201, description: 'User registered' })
|
||||
@ApiBadRequestResponse({ description: 'Invalid registration data' })
|
||||
async register(@Body() body: RegisterRequestDto) {
|
||||
const user = await this.authService.register(body);
|
||||
this.logger.log(`Registered user: ${user.id}`);
|
||||
return { id: user.id };
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
|
||||
async login(@Body() body: LoginRequestDto): Promise<LoginResponseDto> {
|
||||
return this.authService.login(body.email, body.password);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Change current user password' })
|
||||
@ApiResponse({ status: 204, description: 'Password updated' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
|
||||
async changePassword(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() body: ChangePasswordDto,
|
||||
): Promise<void> {
|
||||
await this.authService.changePassword(
|
||||
user.userId,
|
||||
body.currentPassword,
|
||||
body.newPassword,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('reset-password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Reset password with old password' })
|
||||
@ApiResponse({ status: 204, description: 'Password updated' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
|
||||
async resetPassword(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() body: ResetPasswordDto,
|
||||
): Promise<void> {
|
||||
await this.authService.changePassword(
|
||||
user.userId,
|
||||
body.oldPassword,
|
||||
body.newPassword,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
async refresh(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<LoginResponseDto> {
|
||||
return this.authService.refreshToken(user);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { JwtVerificationService } from './services/jwt-verification.service';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -12,6 +13,7 @@ import { UsersModule } from '../users/users.module';
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
forwardRef(() => UsersModule),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [JwtStrategy, AuthService, JwtVerificationService],
|
||||
exports: [AuthService, PassportModule, JwtVerificationService],
|
||||
})
|
||||
|
||||
@@ -1,449 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { URLSearchParams } from 'url';
|
||||
import axios from 'axios';
|
||||
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 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'),
|
||||
activeDollId: null,
|
||||
};
|
||||
|
||||
const mockUsersService: jest.Mocked<
|
||||
Pick<UsersService, 'createFromToken' | 'findByKeycloakSub' | 'findOrCreate'>
|
||||
> = {
|
||||
createFromToken: jest.fn().mockResolvedValue(mockUser),
|
||||
findByKeycloakSub: jest.fn().mockResolvedValue(null),
|
||||
findOrCreate: jest.fn().mockResolvedValue(mockUser),
|
||||
};
|
||||
|
||||
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'],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: UsersService,
|
||||
useValue: mockUsersService,
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: (key: string) => {
|
||||
if (key === 'JWT_ISSUER') return 'https://auth.example.com';
|
||||
if (key === 'KEYCLOAK_CLIENT_ID') return 'friendolls-client';
|
||||
if (key === 'KEYCLOAK_CLIENT_SECRET') return 'secret';
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('revokeToken', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(axios, 'post').mockReset();
|
||||
});
|
||||
|
||||
it('should skip when config missing', async () => {
|
||||
const missingConfigService = new ConfigService({});
|
||||
const localService = new AuthService(
|
||||
mockUsersService as unknown as UsersService,
|
||||
missingConfigService,
|
||||
);
|
||||
|
||||
const warnSpy = jest.spyOn<any, any>(localService['logger'], 'warn');
|
||||
const result = await localService.revokeToken('rt');
|
||||
expect(result).toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return true on successful revocation', async () => {
|
||||
jest.spyOn(axios, 'post').mockResolvedValue({ status: 200 });
|
||||
|
||||
const result = await service.revokeToken('rt-success');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'https://auth.example.com/protocol/openid-connect/revoke',
|
||||
expect.any(URLSearchParams),
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false on non-2xx response', async () => {
|
||||
jest.spyOn(axios, 'post').mockResolvedValue({ status: 400 });
|
||||
const warnSpy = jest.spyOn<any, any>(service['logger'], 'warn');
|
||||
|
||||
const result = await service.revokeToken('rt-fail');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false on error', async () => {
|
||||
jest.spyOn(axios, 'post').mockRejectedValue({ message: 'boom' });
|
||||
const warnSpy = jest.spyOn<any, any>(service['logger'], 'warn');
|
||||
|
||||
const result = await service.revokeToken('rt-error');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncUserFromToken', () => {
|
||||
it('should create a new user if user does not exist', async () => {
|
||||
mockUsersService.createFromToken.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.syncUserFromToken(mockAuthUser);
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(mockUsersService.createFromToken).toHaveBeenCalledWith({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle existing user via upsert', async () => {
|
||||
const updatedUser = { ...mockUser, lastLoginAt: new Date('2024-02-01') };
|
||||
mockUsersService.createFromToken.mockResolvedValue(updatedUser);
|
||||
|
||||
const result = await service.syncUserFromToken(mockAuthUser);
|
||||
|
||||
expect(result).toEqual(updatedUser);
|
||||
expect(mockUsersService.createFromToken).toHaveBeenCalledWith({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle user with no email by using empty string', async () => {
|
||||
const authUserNoEmail: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:user456',
|
||||
name: 'No Email User',
|
||||
};
|
||||
|
||||
mockUsersService.createFromToken.mockResolvedValue({
|
||||
...mockUser,
|
||||
email: '',
|
||||
name: 'No Email User',
|
||||
});
|
||||
|
||||
await service.syncUserFromToken(authUserNoEmail);
|
||||
|
||||
expect(mockUsersService.createFromToken).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: 'fallbackuser',
|
||||
};
|
||||
|
||||
mockUsersService.createFromToken.mockResolvedValue({
|
||||
...mockUser,
|
||||
name: 'fallbackuser',
|
||||
});
|
||||
|
||||
await service.syncUserFromToken(authUserNoName);
|
||||
|
||||
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'fallbackuser',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use "Unknown User" when no name or username is available', async () => {
|
||||
const authUserMinimal: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:minimal',
|
||||
};
|
||||
|
||||
mockUsersService.createFromToken.mockResolvedValue({
|
||||
...mockUser,
|
||||
name: 'Unknown User',
|
||||
});
|
||||
|
||||
await service.syncUserFromToken(authUserMinimal);
|
||||
|
||||
expect(mockUsersService.createFromToken).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',
|
||||
};
|
||||
|
||||
mockUsersService.createFromToken.mockResolvedValue({
|
||||
...mockUser,
|
||||
keycloakSub: '',
|
||||
email: 'empty@example.com',
|
||||
name: 'Empty Sub User',
|
||||
});
|
||||
|
||||
await service.syncUserFromToken(authUserEmptySub);
|
||||
|
||||
expect(mockUsersService.createFromToken).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',
|
||||
};
|
||||
|
||||
mockUsersService.createFromToken.mockResolvedValue({
|
||||
...mockUser,
|
||||
keycloakSub: 'invalid-format',
|
||||
email: 'malformed@example.com',
|
||||
name: 'Malformed User',
|
||||
});
|
||||
|
||||
const result = await service.syncUserFromToken(authUserMalformed);
|
||||
|
||||
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
keycloakSub: 'invalid-format',
|
||||
email: 'malformed@example.com',
|
||||
name: 'Malformed User',
|
||||
}),
|
||||
);
|
||||
expect(result.keycloakSub).toBe('invalid-format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureUserExists', () => {
|
||||
it('should call findOrCreate with correct params', async () => {
|
||||
mockUsersService.findOrCreate.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.ensureUserExists(mockAuthUser);
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(mockUsersService.findOrCreate).toHaveBeenCalledWith({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing username gracefully', async () => {
|
||||
mockUsersService.findOrCreate.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.ensureUserExists({
|
||||
...mockAuthUser,
|
||||
username: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(mockUsersService.findOrCreate).toHaveBeenCalledWith({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: undefined,
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,159 +1,173 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { URLSearchParams } from 'url';
|
||||
import { sign } from 'jsonwebtoken';
|
||||
import { compare, hash } from 'bcryptjs';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import type { AuthenticatedUser } from './decorators/current-user.decorator';
|
||||
import { User } from '../users/users.entity';
|
||||
import type { AuthenticatedUser } from './decorators/current-user.decorator';
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
*
|
||||
* Handles authentication-related business logic including:
|
||||
* - User login tracking from Keycloak tokens
|
||||
* - Profile synchronization from Keycloak
|
||||
* - Role-based authorization checks
|
||||
*
|
||||
* ## User Sync Strategy
|
||||
*
|
||||
* On every authentication:
|
||||
* - Creates new users with full profile data from Keycloak
|
||||
* - For existing users, compares Keycloak data with local database:
|
||||
* - If profile changed: Updates all fields (email, name, username, picture, roles, lastLoginAt)
|
||||
* - If profile unchanged: Only updates lastLoginAt (lightweight operation)
|
||||
*
|
||||
* This optimizes database performance since reads are cheaper than writes in PostgreSQL.
|
||||
* Most logins only update lastLoginAt, but profile changes sync automatically.
|
||||
*
|
||||
* For explicit profile sync (webhooks, admin operations), use:
|
||||
* - UsersService.syncProfileFromToken() - force sync regardless of changes
|
||||
*
|
||||
* ## Usage Guidelines
|
||||
*
|
||||
* - Use `syncUserFromToken()` for actual login events (WebSocket connections, /users/me)
|
||||
* - Use `ensureUserExists()` for regular API calls that just need the user record
|
||||
* Handles native authentication:
|
||||
* - User registration
|
||||
* - Login with email/password
|
||||
* - JWT issuance
|
||||
* - Password changes
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
private readonly jwtSecret: string;
|
||||
private readonly jwtIssuer: string;
|
||||
private readonly jwtAudience?: string;
|
||||
private readonly jwtExpiresInSeconds: number;
|
||||
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Revoke refresh token via Keycloak token revocation endpoint, if configured.
|
||||
* Returns true on success; false on missing config or failure.
|
||||
*/
|
||||
async revokeToken(refreshToken: string): Promise<boolean> {
|
||||
const issuer = this.configService.get<string>('JWT_ISSUER');
|
||||
const clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID');
|
||||
const clientSecret = this.configService.get<string>(
|
||||
'KEYCLOAK_CLIENT_SECRET',
|
||||
) {
|
||||
this.jwtSecret = this.configService.get<string>('JWT_SECRET') || '';
|
||||
if (!this.jwtSecret) {
|
||||
throw new Error('JWT_SECRET must be configured');
|
||||
}
|
||||
this.jwtIssuer =
|
||||
this.configService.get<string>('JWT_ISSUER') || 'friendolls';
|
||||
this.jwtAudience = this.configService.get<string>('JWT_AUDIENCE');
|
||||
this.jwtExpiresInSeconds = Number(
|
||||
this.configService.get<string>('JWT_EXPIRES_IN_SECONDS') || '3600',
|
||||
);
|
||||
|
||||
if (!issuer || !clientId) {
|
||||
this.logger.warn(
|
||||
'JWT issuer or client id missing, skipping token revocation',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const revokeUrl = `${issuer}/protocol/openid-connect/revoke`;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
token: refreshToken,
|
||||
token_type_hint: 'refresh_token',
|
||||
});
|
||||
if (clientSecret) {
|
||||
params.set('client_secret', clientSecret);
|
||||
async register(data: {
|
||||
email: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
}): Promise<User> {
|
||||
const { email, password, name, username } = data;
|
||||
|
||||
const existing = await this.usersService.findByEmail(email);
|
||||
if (existing) {
|
||||
throw new BadRequestException('Email already registered');
|
||||
}
|
||||
|
||||
const response = await axios.post(revokeUrl, params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
timeout: 5000,
|
||||
validateStatus: (status) => status >= 200 && status < 500,
|
||||
});
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
this.logger.log('Refresh token revoked');
|
||||
return true;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`Token revocation failed with status ${response.status}`,
|
||||
);
|
||||
return false;
|
||||
} catch (error) {
|
||||
const err = error as AxiosError;
|
||||
this.logger.warn(
|
||||
`Failed to revoke token: ${err.response?.status} ${err.response?.statusText ?? err.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user login and profile synchronization from Keycloak token.
|
||||
* Creates new users with full profile data.
|
||||
* For existing users, intelligently syncs only changed fields to optimize performance.
|
||||
*
|
||||
* The service compares Keycloak data with local database and:
|
||||
* - Updates profile fields only if they changed from Keycloak
|
||||
* - Always updates lastLoginAt to track login activity
|
||||
*
|
||||
* @param authenticatedUser - User data extracted from JWT token
|
||||
* @returns The user entity
|
||||
*/
|
||||
async syncUserFromToken(authenticatedUser: AuthenticatedUser): Promise<User> {
|
||||
const { keycloakSub, email, name, username, picture, roles } =
|
||||
authenticatedUser;
|
||||
|
||||
const user = await this.usersService.createFromToken({
|
||||
keycloakSub,
|
||||
email: email || '',
|
||||
const passwordHash = await hash(password, 12);
|
||||
return this.usersService.createLocalUser({
|
||||
email,
|
||||
passwordHash,
|
||||
name: name || username || 'Unknown User',
|
||||
username,
|
||||
picture,
|
||||
roles,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a user exists in the local database without updating profile data.
|
||||
* This is optimized for regular API calls that need the user record but don't
|
||||
* need to sync profile data from Keycloak on every request.
|
||||
*/
|
||||
async ensureUserExists(authenticatedUser: AuthenticatedUser): Promise<User> {
|
||||
const { keycloakSub, email, name, username, picture, roles } =
|
||||
authenticatedUser;
|
||||
|
||||
return await this.usersService.findOrCreate({
|
||||
keycloakSub,
|
||||
email: email || '',
|
||||
name: name || username || 'Unknown User',
|
||||
username,
|
||||
picture,
|
||||
roles,
|
||||
});
|
||||
}
|
||||
|
||||
hasRole(user: AuthenticatedUser, requiredRole: string): boolean {
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
const user = await this.usersService.findByEmail(email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const passwordOk = await this.verifyPassword(user, password);
|
||||
if (!passwordOk) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
await this.usersService.updateLastLogin(user.id);
|
||||
|
||||
const accessToken = this.issueToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
roles: user.roles,
|
||||
});
|
||||
|
||||
return { accessToken, expiresIn: this.jwtExpiresInSeconds };
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
const user = await this.usersService.findOne(userId);
|
||||
|
||||
const passwordOk = await this.verifyPassword(user, currentPassword);
|
||||
if (!passwordOk) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const passwordHash = await hash(newPassword, 12);
|
||||
await this.usersService.updatePasswordHash(userId, passwordHash);
|
||||
}
|
||||
|
||||
async refreshToken(user: AuthenticatedUser): Promise<{
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
const existingUser = await this.usersService.findOne(user.userId);
|
||||
const accessToken = this.issueToken({
|
||||
userId: existingUser.id,
|
||||
email: existingUser.email,
|
||||
roles: existingUser.roles,
|
||||
});
|
||||
|
||||
return { accessToken, expiresIn: this.jwtExpiresInSeconds };
|
||||
}
|
||||
|
||||
private issueToken(payload: {
|
||||
userId: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
}): string {
|
||||
return sign(
|
||||
{
|
||||
sub: payload.userId,
|
||||
email: payload.email,
|
||||
roles: payload.roles,
|
||||
},
|
||||
this.jwtSecret,
|
||||
{
|
||||
issuer: this.jwtIssuer,
|
||||
audience: this.jwtAudience,
|
||||
expiresIn: this.jwtExpiresInSeconds,
|
||||
algorithm: 'HS256',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async verifyPassword(user: User, password: string): Promise<boolean> {
|
||||
const userWithPassword = user as unknown as {
|
||||
passwordHash?: string | null;
|
||||
};
|
||||
if (userWithPassword.passwordHash) {
|
||||
return compare(password, userWithPassword.passwordHash);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
hasRole(user: { roles?: string[] }, requiredRole: string): boolean {
|
||||
return user.roles?.includes(requiredRole) ?? false;
|
||||
}
|
||||
|
||||
hasAnyRole(user: AuthenticatedUser, requiredRoles: string[]): boolean {
|
||||
hasAnyRole(user: { roles?: string[] }, requiredRoles: string[]): boolean {
|
||||
if (!user.roles || user.roles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return requiredRoles.some((role) => user.roles!.includes(role));
|
||||
}
|
||||
|
||||
hasAllRoles(user: AuthenticatedUser, requiredRoles: string[]): boolean {
|
||||
hasAllRoles(user: { roles?: string[] }, requiredRoles: string[]): boolean {
|
||||
if (!user.roles || user.roles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,9 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
* This matches the object returned by JwtStrategy.validate()
|
||||
*/
|
||||
export interface AuthenticatedUser {
|
||||
keycloakSub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
roles?: string[];
|
||||
sessionState?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,8 +30,8 @@ export interface AuthenticatedUser {
|
||||
* ```typescript
|
||||
* @Get('profile')
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* async getProfile(@CurrentUser('keycloakSub') sub: string) {
|
||||
* return { sub };
|
||||
* async getProfile(@CurrentUser('userId') userId: string) {
|
||||
* return { userId };
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
15
src/auth/dto/change-password.dto.ts
Normal file
15
src/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ example: 'old password' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
currentPassword!: string;
|
||||
|
||||
@ApiProperty({ example: 'new strong password' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
newPassword!: string;
|
||||
}
|
||||
14
src/auth/dto/login-request.dto.ts
Normal file
14
src/auth/dto/login-request.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginRequestDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: 'correct horse battery staple' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(3)
|
||||
password!: string;
|
||||
}
|
||||
9
src/auth/dto/login-response.dto.ts
Normal file
9
src/auth/dto/login-response.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginResponseDto {
|
||||
@ApiProperty({ description: 'JWT access token' })
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty({ description: 'Access token expiration in seconds' })
|
||||
expiresIn: number;
|
||||
}
|
||||
30
src/auth/dto/register-request.dto.ts
Normal file
30
src/auth/dto/register-request.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RegisterRequestDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: 'correct horse battery staple' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Jane Doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'janedoe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
}
|
||||
15
src/auth/dto/reset-password.dto.ts
Normal file
15
src/auth/dto/reset-password.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@ApiProperty({ example: 'old password' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
oldPassword!: string;
|
||||
|
||||
@ApiProperty({ example: 'new strong password' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
newPassword!: string;
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { ExecutionContext, Injectable, Logger } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { Request } from 'express';
|
||||
import { User } from 'src/users/users.entity';
|
||||
import type { AuthenticatedUser } from '../decorators/current-user.decorator';
|
||||
|
||||
/**
|
||||
* JWT Authentication Guard
|
||||
*
|
||||
* This guard protects routes by requiring a valid JWT token from Keycloak.
|
||||
* This guard protects routes by requiring a valid JWT token.
|
||||
* It uses the JwtStrategy to validate the token and attach user info to the request.
|
||||
*
|
||||
* Usage:
|
||||
@@ -57,7 +57,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
|
||||
handleRequest(
|
||||
err: any,
|
||||
user: User,
|
||||
user: AuthenticatedUser,
|
||||
info: any,
|
||||
context: ExecutionContext,
|
||||
status?: any,
|
||||
@@ -82,7 +82,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
}
|
||||
} else {
|
||||
this.logger.debug(
|
||||
`✅ JWT Authentication successful for user: ${user.keycloakSub || 'unknown'}`,
|
||||
`✅ JWT Authentication successful for user: ${user.userId || 'unknown'}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('JwtVerificationService', () => {
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
const config: Record<string, string> = {
|
||||
JWKS_URI: 'https://test.com/.well-known/jwks.json',
|
||||
JWT_SECRET: 'test-secret',
|
||||
JWT_ISSUER: 'https://test.com',
|
||||
JWT_AUDIENCE: 'test-audience',
|
||||
};
|
||||
|
||||
@@ -1,78 +1,36 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { verify, type JwtHeader } from 'jsonwebtoken';
|
||||
import { JwksClient, type SigningKey } from 'jwks-rsa';
|
||||
import { verify } from 'jsonwebtoken';
|
||||
import type { JwtPayload } from '../strategies/jwt.strategy';
|
||||
|
||||
const JWT_ALGORITHM = 'RS256';
|
||||
const JWT_ALGORITHM = 'HS256';
|
||||
const BEARER_PREFIX = 'Bearer ';
|
||||
|
||||
@Injectable()
|
||||
export class JwtVerificationService {
|
||||
private readonly logger = new Logger(JwtVerificationService.name);
|
||||
private readonly jwksClient: JwksClient;
|
||||
private readonly jwtSecret: string;
|
||||
private readonly issuer: string;
|
||||
private readonly audience: string | undefined;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const jwksUri = this.configService.get<string>('JWKS_URI');
|
||||
this.issuer = this.configService.get<string>('JWT_ISSUER') || '';
|
||||
this.jwtSecret = this.configService.get<string>('JWT_SECRET') || '';
|
||||
this.issuer = this.configService.get<string>('JWT_ISSUER') || 'friendolls';
|
||||
this.audience = this.configService.get<string>('JWT_AUDIENCE');
|
||||
|
||||
if (!jwksUri) {
|
||||
throw new Error('JWKS_URI must be configured');
|
||||
if (!this.jwtSecret) {
|
||||
throw new Error('JWT_SECRET must be configured');
|
||||
}
|
||||
|
||||
if (!this.issuer) {
|
||||
throw new Error('JWT_ISSUER must be configured');
|
||||
}
|
||||
|
||||
this.jwksClient = new JwksClient({
|
||||
jwksUri,
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
jwksRequestsPerMinute: 5,
|
||||
});
|
||||
|
||||
this.logger.log('JWT Verification Service initialized');
|
||||
}
|
||||
|
||||
async verifyToken(token: string): Promise<JwtPayload> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const getKey = (
|
||||
header: JwtHeader,
|
||||
callback: (err: Error | null, signingKey?: string | Buffer) => void,
|
||||
) => {
|
||||
this.jwksClient.getSigningKey(
|
||||
header.kid,
|
||||
(err: Error | null, key?: SigningKey) => {
|
||||
if (err) {
|
||||
callback(err);
|
||||
return;
|
||||
}
|
||||
const signingKey = key?.getPublicKey();
|
||||
callback(null, signingKey);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
verify(
|
||||
token,
|
||||
getKey,
|
||||
{
|
||||
verifyToken(token: string): JwtPayload {
|
||||
return verify(token, this.jwtSecret, {
|
||||
issuer: this.issuer,
|
||||
audience: this.audience,
|
||||
algorithms: [JWT_ALGORITHM],
|
||||
},
|
||||
(err, decoded) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(decoded as JwtPayload);
|
||||
},
|
||||
);
|
||||
});
|
||||
}) as JwtPayload;
|
||||
}
|
||||
|
||||
extractToken(handshake: {
|
||||
|
||||
@@ -1,83 +1,48 @@
|
||||
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';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* JWT payload interface representing the decoded token from Keycloak
|
||||
* JWT payload interface representing the decoded token
|
||||
*/
|
||||
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[];
|
||||
};
|
||||
};
|
||||
session_state?: string;
|
||||
iss: string; // Issuer
|
||||
aud: string | string[]; // Audience
|
||||
exp: number; // Expiration time
|
||||
iat: number; // Issued at
|
||||
sub: string; // User ID
|
||||
email: string;
|
||||
roles?: string[];
|
||||
iss: string;
|
||||
aud?: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* JWT Strategy for validating locally issued JWT tokens.
|
||||
*/
|
||||
@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 jwtSecret = configService.get<string>('JWT_SECRET');
|
||||
const issuer = configService.get<string>('JWT_ISSUER') || 'friendolls';
|
||||
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');
|
||||
if (!jwtSecret) {
|
||||
throw new Error('JWT_SECRET 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
|
||||
secretOrKey: jwtSecret,
|
||||
issuer,
|
||||
|
||||
// Verify the audience matches our client ID
|
||||
audience,
|
||||
|
||||
// Automatically reject expired tokens
|
||||
ignoreExpiration: false,
|
||||
|
||||
// Use RS256 algorithm (Keycloak's default)
|
||||
algorithms: ['RS256'],
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
|
||||
this.logger.log(`JWT Strategy initialized`);
|
||||
this.logger.log(` JWKS URI: ${jwksUri}`);
|
||||
this.logger.log(` Issuer: ${issuer}`);
|
||||
this.logger.log(` Audience: ${audience || 'NOT SET'}`);
|
||||
}
|
||||
@@ -91,19 +56,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
* @throws UnauthorizedException if the payload is invalid
|
||||
*/
|
||||
async validate(payload: JwtPayload): Promise<{
|
||||
keycloakSub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
roles?: string[];
|
||||
sessionState?: string;
|
||||
}> {
|
||||
this.logger.debug(`Validating JWT token payload`);
|
||||
this.logger.debug(` Issuer: ${payload.iss}`);
|
||||
this.logger.debug(
|
||||
` Audience: ${Array.isArray(payload.aud) ? payload.aud.join(',') : payload.aud}`,
|
||||
);
|
||||
if (payload.aud) {
|
||||
this.logger.debug(` Audience: ${payload.aud}`);
|
||||
}
|
||||
this.logger.debug(` Subject: ${payload.sub}`);
|
||||
this.logger.debug(
|
||||
` Expires: ${new Date(payload.exp * 1000).toISOString()}`,
|
||||
@@ -114,31 +75,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
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,
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
username: payload.preferred_username,
|
||||
picture: payload.picture,
|
||||
roles: roles.length > 0 ? roles : undefined,
|
||||
sessionState: payload.session_state,
|
||||
roles:
|
||||
payload.roles && payload.roles.length > 0 ? payload.roles : undefined,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`✅ Successfully validated token for user: ${payload.sub} (${payload.email ?? payload.preferred_username ?? 'no email'})`,
|
||||
`✅ Successfully validated token for user: ${payload.sub} (${payload.email})`,
|
||||
);
|
||||
|
||||
return Promise.resolve(user);
|
||||
|
||||
@@ -24,17 +24,13 @@ import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '../auth/decorators/current-user.decorator';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
@ApiTags('dolls')
|
||||
@Controller('dolls')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class DollsController {
|
||||
constructor(
|
||||
private readonly dollsService: DollsService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
constructor(private readonly dollsService: DollsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
@@ -51,8 +47,7 @@ export class DollsController {
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
@Body() createDollDto: CreateDollDto,
|
||||
) {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
return this.dollsService.create(user.id, createDollDto);
|
||||
return this.dollsService.create(authUser.userId, createDollDto);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@@ -66,8 +61,7 @@ export class DollsController {
|
||||
})
|
||||
@ApiUnauthorizedResponse({ description: 'Unauthorized' })
|
||||
async listMyDolls(@CurrentUser() authUser: AuthenticatedUser) {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
return this.dollsService.listByOwner(user.id, user.id);
|
||||
return this.dollsService.listByOwner(authUser.userId, authUser.userId);
|
||||
}
|
||||
|
||||
@Get('user/:userId')
|
||||
@@ -89,8 +83,7 @@ export class DollsController {
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
@Param('userId') userId: string,
|
||||
) {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
return this.dollsService.listByOwner(userId, user.id);
|
||||
return this.dollsService.listByOwner(userId, authUser.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@@ -109,8 +102,7 @@ export class DollsController {
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
return this.dollsService.findOne(id, user.id);
|
||||
return this.dollsService.findOne(id, authUser.userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@@ -130,8 +122,7 @@ export class DollsController {
|
||||
@Param('id') id: string,
|
||||
@Body() updateDollDto: UpdateDollDto,
|
||||
) {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
return this.dollsService.update(id, user.id, updateDollDto);
|
||||
return this.dollsService.update(id, authUser.userId, updateDollDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@@ -151,7 +142,6 @@ export class DollsController {
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
return this.dollsService.remove(id, user.id);
|
||||
return this.dollsService.remove(id, authUser.userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { FriendsController } from './friends.controller';
|
||||
import { FriendsService } from './friends.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
// StateGateway removed
|
||||
|
||||
enum FriendRequestStatus {
|
||||
@@ -16,37 +15,38 @@ describe('FriendsController', () => {
|
||||
let controller: FriendsController;
|
||||
|
||||
const mockAuthUser = {
|
||||
keycloakSub: 'f:realm:user1',
|
||||
userId: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
name: 'User One',
|
||||
username: 'user1',
|
||||
roles: [],
|
||||
};
|
||||
|
||||
const mockUser1 = {
|
||||
id: 'user-1',
|
||||
keycloakSub: 'f:realm:user1',
|
||||
keycloakSub: 'legacy-sub-1',
|
||||
email: 'user1@example.com',
|
||||
name: 'User One',
|
||||
username: 'user1',
|
||||
picture: null,
|
||||
roles: [],
|
||||
passwordHash: null,
|
||||
lastLoginAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
} as unknown as { passwordHash: string | null } & Record<string, unknown>;
|
||||
|
||||
const mockUser2 = {
|
||||
id: 'user-2',
|
||||
keycloakSub: 'f:realm:user2',
|
||||
keycloakSub: 'legacy-sub-2',
|
||||
email: 'user2@example.com',
|
||||
name: 'User Two',
|
||||
username: 'user2',
|
||||
picture: null,
|
||||
roles: [],
|
||||
passwordHash: null,
|
||||
lastLoginAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
} as unknown as { passwordHash: string | null } & Record<string, unknown>;
|
||||
|
||||
const mockFriendRequest = {
|
||||
id: 'request-1',
|
||||
@@ -81,11 +81,6 @@ describe('FriendsController', () => {
|
||||
searchUsers: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAuthService = {
|
||||
syncUserFromToken: jest.fn(),
|
||||
ensureUserExists: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
@@ -100,15 +95,12 @@ describe('FriendsController', () => {
|
||||
providers: [
|
||||
{ provide: FriendsService, useValue: mockFriendsService },
|
||||
{ provide: UsersService, useValue: mockUsersService },
|
||||
{ provide: AuthService, useValue: mockAuthService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<FriendsController>(FriendsController);
|
||||
|
||||
jest.clearAllMocks();
|
||||
mockAuthService.syncUserFromToken.mockResolvedValue(mockUser1);
|
||||
mockAuthService.ensureUserExists.mockResolvedValue(mockUser1);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '../auth/decorators/current-user.decorator';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { SendFriendRequestDto } from './dto/send-friend-request.dto';
|
||||
import {
|
||||
FriendRequestResponseDto,
|
||||
@@ -60,7 +59,6 @@ export class FriendsController {
|
||||
constructor(
|
||||
private readonly friendsService: FriendsService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
@Get('search')
|
||||
@@ -87,15 +85,13 @@ export class FriendsController {
|
||||
@Query() searchDto: SearchUsersDto,
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<UserBasicDto[]> {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
|
||||
this.logger.debug(
|
||||
`Searching users with username: ${searchDto.username || 'all'}`,
|
||||
);
|
||||
|
||||
const users = await this.usersService.searchUsers(
|
||||
searchDto.username,
|
||||
user.id,
|
||||
authUser.userId,
|
||||
);
|
||||
|
||||
return users.map((u: User) => ({
|
||||
@@ -135,14 +131,12 @@ export class FriendsController {
|
||||
@Body() sendRequestDto: SendFriendRequestDto,
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<FriendRequestResponseDto> {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
|
||||
this.logger.log(
|
||||
`User ${user.id} sending friend request to ${sendRequestDto.receiverId}`,
|
||||
`User ${authUser.userId} sending friend request to ${sendRequestDto.receiverId}`,
|
||||
);
|
||||
|
||||
const friendRequest = await this.friendsService.sendFriendRequest(
|
||||
user.id,
|
||||
authUser.userId,
|
||||
sendRequestDto.receiverId,
|
||||
);
|
||||
|
||||
@@ -165,12 +159,12 @@ export class FriendsController {
|
||||
async getReceivedRequests(
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<FriendRequestResponseDto[]> {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
|
||||
this.logger.debug(`Getting received friend requests for user ${user.id}`);
|
||||
this.logger.debug(
|
||||
`Getting received friend requests for user ${authUser.userId}`,
|
||||
);
|
||||
|
||||
const requests = await this.friendsService.getPendingReceivedRequests(
|
||||
user.id,
|
||||
authUser.userId,
|
||||
);
|
||||
|
||||
return requests.map((req) => this.mapFriendRequestToDto(req));
|
||||
@@ -192,11 +186,13 @@ export class FriendsController {
|
||||
async getSentRequests(
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<FriendRequestResponseDto[]> {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
this.logger.debug(
|
||||
`Getting sent friend requests for user ${authUser.userId}`,
|
||||
);
|
||||
|
||||
this.logger.debug(`Getting sent friend requests for user ${user.id}`);
|
||||
|
||||
const requests = await this.friendsService.getPendingSentRequests(user.id);
|
||||
const requests = await this.friendsService.getPendingSentRequests(
|
||||
authUser.userId,
|
||||
);
|
||||
|
||||
return requests.map((req) => this.mapFriendRequestToDto(req));
|
||||
}
|
||||
@@ -231,13 +227,13 @@ export class FriendsController {
|
||||
@Param('id') requestId: string,
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<FriendRequestResponseDto> {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
|
||||
this.logger.log(`User ${user.id} accepting friend request ${requestId}`);
|
||||
this.logger.log(
|
||||
`User ${authUser.userId} accepting friend request ${requestId}`,
|
||||
);
|
||||
|
||||
const friendRequest = await this.friendsService.acceptFriendRequest(
|
||||
requestId,
|
||||
user.id,
|
||||
authUser.userId,
|
||||
);
|
||||
|
||||
return this.mapFriendRequestToDto(friendRequest);
|
||||
@@ -273,13 +269,13 @@ export class FriendsController {
|
||||
@Param('id') requestId: string,
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<FriendRequestResponseDto> {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
|
||||
this.logger.log(`User ${user.id} denying friend request ${requestId}`);
|
||||
this.logger.log(
|
||||
`User ${authUser.userId} denying friend request ${requestId}`,
|
||||
);
|
||||
|
||||
const friendRequest = await this.friendsService.denyFriendRequest(
|
||||
requestId,
|
||||
user.id,
|
||||
authUser.userId,
|
||||
);
|
||||
|
||||
return this.mapFriendRequestToDto(friendRequest);
|
||||
@@ -301,11 +297,9 @@ export class FriendsController {
|
||||
async getFriends(
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<FriendshipResponseDto[]> {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
this.logger.debug(`Getting friends list for user ${authUser.userId}`);
|
||||
|
||||
this.logger.debug(`Getting friends list for user ${user.id}`);
|
||||
|
||||
const friendships = await this.friendsService.getFriends(user.id);
|
||||
const friendships = await this.friendsService.getFriends(authUser.userId);
|
||||
|
||||
return friendships.map((friendship) => {
|
||||
// Use Prisma generated type for safe casting
|
||||
@@ -366,11 +360,9 @@ export class FriendsController {
|
||||
@Param('friendId') friendId: string,
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<void> {
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
this.logger.log(`User ${authUser.userId} unfriending user ${friendId}`);
|
||||
|
||||
this.logger.log(`User ${user.id} unfriending user ${friendId}`);
|
||||
|
||||
await this.friendsService.unfriend(user.id, friendId);
|
||||
await this.friendsService.unfriend(authUser.userId, friendId);
|
||||
}
|
||||
|
||||
private mapFriendRequestToDto(
|
||||
|
||||
@@ -20,29 +20,31 @@ describe('FriendsService', () => {
|
||||
|
||||
const mockUser1 = {
|
||||
id: 'user-1',
|
||||
keycloakSub: 'f:realm:user1',
|
||||
keycloakSub: 'legacy-sub-1',
|
||||
email: 'user1@example.com',
|
||||
name: 'User One',
|
||||
username: 'user1',
|
||||
picture: null,
|
||||
roles: [],
|
||||
passwordHash: null,
|
||||
lastLoginAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
} as unknown as { passwordHash: string | null } & Record<string, unknown>;
|
||||
|
||||
const mockUser2 = {
|
||||
id: 'user-2',
|
||||
keycloakSub: 'f:realm:user2',
|
||||
keycloakSub: 'legacy-sub-2',
|
||||
email: 'user2@example.com',
|
||||
name: 'User Two',
|
||||
username: 'user2',
|
||||
picture: null,
|
||||
roles: [],
|
||||
passwordHash: null,
|
||||
lastLoginAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
} as unknown as { passwordHash: string | null } & Record<string, unknown>;
|
||||
|
||||
const mockFriendRequest = {
|
||||
id: 'request-1',
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class LogoutRequestDto {
|
||||
@ApiProperty({ description: 'Refresh token to revoke' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refreshToken!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Session state identifier' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sessionState?: string;
|
||||
}
|
||||
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -13,45 +12,33 @@ describe('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 mockEnsureUserExists = jest.fn();
|
||||
|
||||
const mockAuthService = {
|
||||
syncUserFromToken: mockSyncUserFromToken,
|
||||
ensureUserExists: mockEnsureUserExists,
|
||||
};
|
||||
|
||||
const mockAuthUser: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:user123',
|
||||
userId: 'uuid-123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
roles: ['user'],
|
||||
};
|
||||
|
||||
const mockUser: User = {
|
||||
const mockUser = {
|
||||
id: 'uuid-123',
|
||||
keycloakSub: 'f:realm:user123',
|
||||
keycloakSub: 'legacy-sub',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
picture: null,
|
||||
roles: ['user'],
|
||||
passwordHash: null,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
lastLoginAt: new Date('2024-01-01'),
|
||||
activeDollId: null,
|
||||
};
|
||||
} as unknown as User & { passwordHash: string | null };
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -61,10 +48,6 @@ describe('UsersController', () => {
|
||||
provide: UsersService,
|
||||
useValue: mockUsersService,
|
||||
},
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: mockAuthService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -79,13 +62,13 @@ describe('UsersController', () => {
|
||||
});
|
||||
|
||||
describe('getCurrentUser', () => {
|
||||
it('should return the current user and sync from token', async () => {
|
||||
mockSyncUserFromToken.mockResolvedValue(mockUser);
|
||||
it('should return the current user', async () => {
|
||||
mockFindOne.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await controller.getCurrentUser(mockAuthUser);
|
||||
|
||||
expect(result).toBe(mockUser);
|
||||
expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser);
|
||||
expect(mockFindOne).toHaveBeenCalledWith(mockAuthUser.userId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +77,6 @@ describe('UsersController', () => {
|
||||
const updateDto: UpdateUserDto = { name: 'Updated Name' };
|
||||
const updatedUser = { ...mockUser, name: 'Updated Name' };
|
||||
|
||||
mockEnsureUserExists.mockResolvedValue(mockUser);
|
||||
mockUpdate.mockResolvedValue(updatedUser);
|
||||
|
||||
const result = await controller.updateCurrentUser(
|
||||
@@ -103,11 +85,10 @@ describe('UsersController', () => {
|
||||
);
|
||||
|
||||
expect(result).toBe(updatedUser);
|
||||
expect(mockEnsureUserExists).toHaveBeenCalledWith(mockAuthUser);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockAuthUser.userId,
|
||||
updateDto,
|
||||
mockAuthUser.keycloakSub,
|
||||
mockAuthUser.userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -160,7 +141,7 @@ describe('UsersController', () => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
updateDto,
|
||||
mockAuthUser.keycloakSub,
|
||||
mockAuthUser.userId,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -189,15 +170,13 @@ describe('UsersController', () => {
|
||||
|
||||
describe('deleteCurrentUser', () => {
|
||||
it('should delete the current user account', async () => {
|
||||
mockEnsureUserExists.mockResolvedValue(mockUser);
|
||||
mockDelete.mockResolvedValue(undefined);
|
||||
|
||||
await controller.deleteCurrentUser(mockAuthUser);
|
||||
|
||||
expect(mockEnsureUserExists).toHaveBeenCalledWith(mockAuthUser);
|
||||
expect(mockDelete).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockAuthUser.keycloakSub,
|
||||
mockAuthUser.userId,
|
||||
mockAuthUser.userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -208,10 +187,7 @@ describe('UsersController', () => {
|
||||
|
||||
await controller.delete(mockUser.id, mockAuthUser);
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockAuthUser.keycloakSub,
|
||||
);
|
||||
expect(mockDelete).toHaveBeenCalledWith(mockUser.id, mockAuthUser.userId);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException if deleting another user', async () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
HttpCode,
|
||||
UseGuards,
|
||||
Logger,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
ApiBearerAuth,
|
||||
ApiUnauthorizedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { User, UserResponseDto } from './users.entity';
|
||||
@@ -28,17 +26,12 @@ import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '../auth/decorators/current-user.decorator';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { LogoutRequestDto } from './dto/logout-request.dto';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* All endpoints require authentication via JWT.
|
||||
*/
|
||||
@ApiTags('users')
|
||||
@Controller('users')
|
||||
@@ -47,43 +40,15 @@ import { LogoutRequestDto } from './dto/logout-request.dto';
|
||||
export class UsersController {
|
||||
private readonly logger = new Logger(UsersController.name);
|
||||
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Logout current authenticated session by revoking the refresh token.
|
||||
* Falls back to local cleanup only if revocation fails.
|
||||
*/
|
||||
@Post('logout')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Logout current session' })
|
||||
@ApiNoContentResponse({ description: 'Session logged out' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid or missing JWT token' })
|
||||
async logout(
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
@Body() body: LogoutRequestDto,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Logout requested for ${authUser.keycloakSub}`);
|
||||
const refreshToken = body.refreshToken;
|
||||
|
||||
const revoked = await this.authService.revokeToken(refreshToken);
|
||||
if (!revoked) {
|
||||
this.logger.warn('Token revocation failed or was skipped');
|
||||
}
|
||||
}
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
/**
|
||||
* Get current authenticated user's profile.
|
||||
* This endpoint syncs the user from Keycloak token to ensure profile data
|
||||
* is up-to-date when explicitly requested by the user.
|
||||
*/
|
||||
@Get('me')
|
||||
@ApiOperation({
|
||||
summary: 'Get current user profile',
|
||||
description:
|
||||
'Returns the authenticated user profile. Automatically syncs data from Keycloak token.',
|
||||
description: 'Returns the authenticated user profile.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -96,13 +61,9 @@ export class UsersController {
|
||||
async getCurrentUser(
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<User> {
|
||||
this.logger.debug(`Get current user: ${authUser.keycloakSub}`);
|
||||
this.logger.debug(`Get current user: ${authUser.userId}`);
|
||||
|
||||
// Sync user from token - this is one of the few endpoints that should
|
||||
// actively sync profile data, as it's an explicit request for user info
|
||||
const user = await this.authService.syncUserFromToken(authUser);
|
||||
|
||||
return user;
|
||||
return this.usersService.findOne(authUser.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,16 +91,12 @@ export class UsersController {
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
): Promise<User> {
|
||||
this.logger.log(`Update current user: ${authUser.keycloakSub}`);
|
||||
this.logger.log(`Update current user: ${authUser.userId}`);
|
||||
|
||||
// First ensure user exists in our system
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
|
||||
// Update the user's profile
|
||||
return this.usersService.update(
|
||||
user.id,
|
||||
authUser.userId,
|
||||
updateUserDto,
|
||||
authUser.keycloakSub,
|
||||
authUser.userId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,7 +132,7 @@ export class UsersController {
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<User> {
|
||||
this.logger.debug(
|
||||
`Get user by ID: ${id} (requested by ${authUser.keycloakSub})`,
|
||||
`Get user by ID: ${id} (requested by ${authUser.userId})`,
|
||||
);
|
||||
return this.usersService.findOne(id);
|
||||
}
|
||||
@@ -219,20 +176,20 @@ export class UsersController {
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<User> {
|
||||
this.logger.log(`Update user ${id} (requested by ${authUser.keycloakSub})`);
|
||||
return this.usersService.update(id, updateUserDto, authUser.keycloakSub);
|
||||
this.logger.log(`Update user ${id} (requested by ${authUser.userId})`);
|
||||
return this.usersService.update(id, updateUserDto, authUser.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete current authenticated user's account.
|
||||
* Note: This only deletes the local user record.
|
||||
* The user still exists in Keycloak and can re-authenticate.
|
||||
* The user data is deleted locally.
|
||||
*/
|
||||
@Delete('me')
|
||||
@ApiOperation({
|
||||
summary: 'Delete current user account',
|
||||
description:
|
||||
'Deletes the authenticated user account. Only removes local data; user still exists in Keycloak.',
|
||||
'Deletes the authenticated user account. Only removes local data.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 204,
|
||||
@@ -245,13 +202,9 @@ export class UsersController {
|
||||
async deleteCurrentUser(
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Delete current user: ${authUser.keycloakSub}`);
|
||||
this.logger.log(`Delete current user: ${authUser.userId}`);
|
||||
|
||||
// First ensure user exists in our system
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
|
||||
// Delete the user's account
|
||||
await this.usersService.delete(user.id, authUser.keycloakSub);
|
||||
await this.usersService.delete(authUser.userId, authUser.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,7 +215,7 @@ export class UsersController {
|
||||
@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.',
|
||||
'Deletes a user account. Users can only delete their own account.',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
@@ -288,8 +241,8 @@ export class UsersController {
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Delete user ${id} (requested by ${authUser.keycloakSub})`);
|
||||
await this.usersService.delete(id, authUser.keycloakSub);
|
||||
this.logger.log(`Delete user ${id} (requested by ${authUser.userId})`);
|
||||
await this.usersService.delete(id, authUser.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,16 +280,12 @@ export class UsersController {
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<User> {
|
||||
this.logger.log(
|
||||
`Set active doll ${dollId} (requested by ${authUser.keycloakSub})`,
|
||||
`Set active doll ${dollId} (requested by ${authUser.userId})`,
|
||||
);
|
||||
|
||||
// First ensure user exists in our system
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
|
||||
return this.usersService.setActiveDoll(
|
||||
user.id,
|
||||
authUser.userId,
|
||||
dollId,
|
||||
authUser.keycloakSub,
|
||||
authUser.userId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -359,13 +308,8 @@ export class UsersController {
|
||||
async removeActiveDoll(
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<User> {
|
||||
this.logger.log(
|
||||
`Remove active doll (requested by ${authUser.keycloakSub})`,
|
||||
);
|
||||
this.logger.log(`Remove active doll (requested by ${authUser.userId})`);
|
||||
|
||||
// First ensure user exists in our system
|
||||
const user = await this.authService.ensureUserExists(authUser);
|
||||
|
||||
return this.usersService.removeActiveDoll(user.id, authUser.keycloakSub);
|
||||
return this.usersService.removeActiveDoll(authUser.userId, authUser.userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { User as PrismaUser } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* User entity representing a user in the system.
|
||||
* Users are synced from Keycloak via OIDC authentication.
|
||||
* Users are authenticated via local JWT-based auth.
|
||||
*
|
||||
* This is a re-export of the Prisma User type for consistency.
|
||||
* Swagger decorators are applied at the controller level.
|
||||
@@ -14,7 +14,7 @@ export type User = PrismaUser;
|
||||
* User response DTO for Swagger documentation
|
||||
* This class is only used for API documentation purposes
|
||||
*/
|
||||
export class UserResponseDto implements PrismaUser {
|
||||
export class UserResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Internal unique identifier',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
@@ -22,10 +22,10 @@ export class UserResponseDto implements PrismaUser {
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Keycloak subject identifier from the JWT token',
|
||||
description: 'Legacy Keycloak subject identifier (migration only)',
|
||||
example: 'f:a1b2c3d4-e5f6-7890-abcd-ef1234567890:johndoe',
|
||||
})
|
||||
keycloakSub: string;
|
||||
keycloakSub: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: "User's display name",
|
||||
@@ -40,7 +40,7 @@ export class UserResponseDto implements PrismaUser {
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "User's preferred username from Keycloak",
|
||||
description: "User's preferred username",
|
||||
example: 'johndoe',
|
||||
required: false,
|
||||
nullable: true,
|
||||
@@ -56,7 +56,7 @@ export class UserResponseDto implements PrismaUser {
|
||||
picture: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
description: "User's roles from Keycloak",
|
||||
description: "User's roles",
|
||||
example: ['user', 'premium'],
|
||||
type: [String],
|
||||
isArray: true,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { WsModule } from '../ws/ws.module';
|
||||
* Users Module
|
||||
*
|
||||
* Manages user-related functionality including user profile management
|
||||
* and synchronization with Keycloak OIDC.
|
||||
* and local authentication.
|
||||
*
|
||||
* The module exports UsersService to allow other modules (like AuthModule)
|
||||
* to access user data and perform synchronization.
|
||||
|
||||
@@ -4,13 +4,12 @@ import { PrismaService } from '../database/prisma.service';
|
||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { User } from '@prisma/client';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/client';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
|
||||
describe('UsersService', () => {
|
||||
let service: UsersService;
|
||||
|
||||
const mockUser: User = {
|
||||
const mockUser: User & { passwordHash?: string | null } = {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
@@ -18,6 +17,7 @@ describe('UsersService', () => {
|
||||
username: 'testuser',
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
passwordHash: null,
|
||||
lastLoginAt: new Date('2024-01-15T10:30:00.000Z'),
|
||||
createdAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-01-15T10:30:00.000Z'),
|
||||
@@ -63,291 +63,29 @@ describe('UsersService', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('createFromToken', () => {
|
||||
it('should create a new user when user does not exist', async () => {
|
||||
const tokenData = {
|
||||
keycloakSub: 'f:realm:newuser',
|
||||
describe('createLocalUser', () => {
|
||||
it('should create a local user with password hash', async () => {
|
||||
const dto = {
|
||||
email: 'john@example.com',
|
||||
name: 'John Doe',
|
||||
username: 'johndoe',
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
passwordHash: 'hashed',
|
||||
};
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.user.upsert.mockResolvedValue({
|
||||
...mockUser,
|
||||
...tokenData,
|
||||
});
|
||||
mockPrismaService.user.create.mockResolvedValue(mockUser);
|
||||
|
||||
const user = await service.createFromToken(tokenData);
|
||||
const user = await service.createLocalUser(dto);
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: tokenData.keycloakSub },
|
||||
});
|
||||
expect(mockPrismaService.user.upsert).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: tokenData.keycloakSub },
|
||||
update: expect.objectContaining({
|
||||
email: tokenData.email,
|
||||
name: tokenData.name,
|
||||
username: tokenData.username,
|
||||
picture: tokenData.picture,
|
||||
roles: tokenData.roles,
|
||||
lastLoginAt: expect.any(Date),
|
||||
}),
|
||||
create: expect.objectContaining({
|
||||
keycloakSub: tokenData.keycloakSub,
|
||||
email: tokenData.email,
|
||||
name: tokenData.name,
|
||||
username: tokenData.username,
|
||||
picture: tokenData.picture,
|
||||
roles: tokenData.roles,
|
||||
lastLoginAt: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should update all fields when profile data changed', async () => {
|
||||
const tokenData = {
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'newemail@example.com', // Changed
|
||||
name: 'New Name', // Changed
|
||||
username: 'testuser',
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
};
|
||||
|
||||
const existingUser = { ...mockUser };
|
||||
const updatedUser = {
|
||||
...mockUser,
|
||||
email: tokenData.email,
|
||||
name: tokenData.name,
|
||||
lastLoginAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
const user = await service.createFromToken(tokenData);
|
||||
|
||||
expect(user).toEqual(updatedUser);
|
||||
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: tokenData.keycloakSub },
|
||||
});
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: tokenData.keycloakSub },
|
||||
data: {
|
||||
email: tokenData.email,
|
||||
name: tokenData.name,
|
||||
username: tokenData.username,
|
||||
picture: tokenData.picture,
|
||||
roles: tokenData.roles,
|
||||
lastLoginAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should only update lastLoginAt when profile unchanged', async () => {
|
||||
const tokenData = {
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com', // Same
|
||||
name: 'Test User', // Same
|
||||
username: 'testuser', // Same
|
||||
picture: 'https://example.com/avatar.jpg', // Same
|
||||
roles: ['user', 'premium'], // Same
|
||||
};
|
||||
|
||||
const existingUser = { ...mockUser };
|
||||
const updatedUser = {
|
||||
...mockUser,
|
||||
lastLoginAt: new Date('2024-02-01T10:00:00.000Z'),
|
||||
};
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
const user = await service.createFromToken(tokenData);
|
||||
|
||||
expect(user).toEqual(updatedUser);
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: tokenData.keycloakSub },
|
||||
data: {
|
||||
lastLoginAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect role changes and update profile', async () => {
|
||||
const tokenData = {
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium', 'admin'], // Changed: added admin
|
||||
};
|
||||
|
||||
const existingUser = { ...mockUser };
|
||||
const updatedUser = {
|
||||
...mockUser,
|
||||
roles: tokenData.roles,
|
||||
lastLoginAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
const user = await service.createFromToken(tokenData);
|
||||
|
||||
expect(user).toEqual(updatedUser);
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: tokenData.keycloakSub },
|
||||
data: {
|
||||
email: tokenData.email,
|
||||
name: tokenData.name,
|
||||
username: tokenData.username,
|
||||
picture: tokenData.picture,
|
||||
roles: tokenData.roles,
|
||||
lastLoginAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle optional fields when creating new user', async () => {
|
||||
const tokenData = {
|
||||
keycloakSub: 'f:realm:user456',
|
||||
email: 'jane@example.com',
|
||||
name: 'Jane Doe',
|
||||
};
|
||||
|
||||
const newUser = { ...mockUser, username: null, picture: null, roles: [] };
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.user.upsert.mockResolvedValue(newUser);
|
||||
|
||||
const user = await service.createFromToken(tokenData);
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(mockPrismaService.user.upsert).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: tokenData.keycloakSub },
|
||||
update: expect.objectContaining({
|
||||
email: tokenData.email,
|
||||
name: tokenData.name,
|
||||
username: undefined,
|
||||
picture: undefined,
|
||||
roles: [],
|
||||
lastLoginAt: expect.any(Date),
|
||||
}),
|
||||
create: expect.objectContaining({
|
||||
keycloakSub: tokenData.keycloakSub,
|
||||
email: tokenData.email,
|
||||
name: tokenData.name,
|
||||
username: undefined,
|
||||
picture: undefined,
|
||||
roles: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize empty roles array', async () => {
|
||||
const tokenData = {
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
roles: undefined,
|
||||
};
|
||||
|
||||
const existingUser = { ...mockUser, roles: ['user'] };
|
||||
const updatedUser = { ...mockUser, roles: [] };
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
await service.createFromToken(tokenData);
|
||||
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: tokenData.keycloakSub },
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
roles: [],
|
||||
email: dto.email,
|
||||
name: dto.name,
|
||||
username: dto.username,
|
||||
passwordHash: dto.passwordHash,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect username change', async () => {
|
||||
const tokenData = {
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'newusername', // Changed
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
};
|
||||
|
||||
const existingUser = { ...mockUser };
|
||||
const updatedUser = { ...mockUser, username: 'newusername' };
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
await service.createFromToken(tokenData);
|
||||
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: tokenData.keycloakSub },
|
||||
data: expect.objectContaining({
|
||||
username: 'newusername',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect picture change', async () => {
|
||||
const tokenData = {
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
picture: 'https://example.com/new-avatar.jpg', // Changed
|
||||
roles: ['user', 'premium'],
|
||||
};
|
||||
|
||||
const existingUser = { ...mockUser };
|
||||
const updatedUser = {
|
||||
...mockUser,
|
||||
picture: 'https://example.com/new-avatar.jpg',
|
||||
};
|
||||
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
await service.createFromToken(tokenData);
|
||||
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: tokenData.keycloakSub },
|
||||
data: expect.objectContaining({
|
||||
picture: 'https://example.com/new-avatar.jpg',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByKeycloakSub', () => {
|
||||
it('should find a user by keycloakSub', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
|
||||
const user = await service.findByKeycloakSub('f:realm:user123');
|
||||
|
||||
expect(user).toEqual(mockUser);
|
||||
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: 'f:realm:user123' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if user not found', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
const user = await service.findByKeycloakSub('nonexistent');
|
||||
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
@@ -382,11 +120,7 @@ describe('UsersService', () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.update.mockResolvedValue(mockUser);
|
||||
|
||||
const user = await service.update(
|
||||
mockUser.id,
|
||||
updateDto,
|
||||
mockUser.keycloakSub,
|
||||
);
|
||||
const user = await service.update(mockUser.id, updateDto, mockUser.id);
|
||||
|
||||
expect(user).toEqual(mockUser);
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
@@ -401,7 +135,7 @@ describe('UsersService', () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(
|
||||
service.update(mockUser.id, updateDto, 'different-keycloak-sub'),
|
||||
service.update(mockUser.id, updateDto, 'different-user-id'),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
|
||||
expect(mockPrismaService.user.update).not.toHaveBeenCalled();
|
||||
@@ -413,7 +147,7 @@ describe('UsersService', () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('nonexistent', updateDto, 'any-keycloak-sub'),
|
||||
service.update('nonexistent', updateDto, 'any-user-id'),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -423,7 +157,7 @@ describe('UsersService', () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.delete.mockResolvedValue(mockUser);
|
||||
|
||||
await service.delete(mockUser.id, mockUser.keycloakSub);
|
||||
await service.delete(mockUser.id, mockUser.id);
|
||||
|
||||
expect(mockPrismaService.user.delete).toHaveBeenCalledWith({
|
||||
where: { id: mockUser.id },
|
||||
@@ -434,7 +168,7 @@ describe('UsersService', () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(
|
||||
service.delete(mockUser.id, 'different-keycloak-sub'),
|
||||
service.delete(mockUser.id, 'different-user-id'),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
|
||||
expect(mockPrismaService.user.delete).not.toHaveBeenCalled();
|
||||
@@ -444,165 +178,11 @@ describe('UsersService', () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.delete('nonexistent', 'any-keycloak-sub'),
|
||||
service.delete('nonexistent', 'any-user-id'),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncProfileFromToken', () => {
|
||||
it('should sync profile data from Keycloak token for existing user', async () => {
|
||||
const profileData = {
|
||||
email: 'updated@example.com',
|
||||
name: 'Updated Name',
|
||||
username: 'updateduser',
|
||||
picture: 'https://example.com/new-avatar.jpg',
|
||||
roles: ['user', 'admin'],
|
||||
};
|
||||
|
||||
const updatedUser = { ...mockUser, ...profileData };
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
const user = await service.syncProfileFromToken(
|
||||
'f:realm:user123',
|
||||
profileData,
|
||||
);
|
||||
|
||||
expect(user).toEqual(updatedUser);
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: 'f:realm:user123' },
|
||||
data: {
|
||||
email: profileData.email,
|
||||
name: profileData.name,
|
||||
username: profileData.username,
|
||||
picture: profileData.picture,
|
||||
roles: profileData.roles,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle profile data with missing optional fields', async () => {
|
||||
const profileData = {
|
||||
email: 'minimal@example.com',
|
||||
name: 'Minimal User',
|
||||
};
|
||||
|
||||
const updatedUser = {
|
||||
...mockUser,
|
||||
...profileData,
|
||||
username: null,
|
||||
picture: null,
|
||||
roles: [],
|
||||
};
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
const user = await service.syncProfileFromToken(
|
||||
'f:realm:user123',
|
||||
profileData,
|
||||
);
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: 'f:realm:user123' },
|
||||
data: {
|
||||
email: profileData.email,
|
||||
name: profileData.name,
|
||||
username: undefined,
|
||||
picture: undefined,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if user not found', async () => {
|
||||
const profileData = {
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const prismaError = new PrismaClientKnownRequestError(
|
||||
'Record not found',
|
||||
{
|
||||
code: 'P2025',
|
||||
clientVersion: '5.0.0',
|
||||
},
|
||||
);
|
||||
|
||||
mockPrismaService.user.update.mockRejectedValue(prismaError);
|
||||
|
||||
await expect(
|
||||
service.syncProfileFromToken('nonexistent', profileData),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(
|
||||
service.syncProfileFromToken('nonexistent', profileData),
|
||||
).rejects.toThrow('User with keycloakSub nonexistent not found');
|
||||
});
|
||||
|
||||
it('should normalize empty roles array', async () => {
|
||||
const profileData = {
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
roles: undefined,
|
||||
};
|
||||
|
||||
const updatedUser = { ...mockUser, roles: [] };
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
await service.syncProfileFromToken('f:realm:user123', profileData);
|
||||
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: 'f:realm:user123' },
|
||||
data: expect.objectContaining({
|
||||
roles: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should overwrite all profile fields from Keycloak', async () => {
|
||||
const profileData = {
|
||||
email: 'keycloak@example.com',
|
||||
name: 'Keycloak Name',
|
||||
username: 'keycloakuser',
|
||||
picture: 'https://keycloak.example.com/avatar.jpg',
|
||||
roles: ['external-role'],
|
||||
};
|
||||
|
||||
const updatedUser = { ...mockUser, ...profileData };
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
const user = await service.syncProfileFromToken(
|
||||
'f:realm:user123',
|
||||
profileData,
|
||||
);
|
||||
|
||||
expect(user.name).toBe('Keycloak Name');
|
||||
expect(user.email).toBe('keycloak@example.com');
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { keycloakSub: 'f:realm:user123' },
|
||||
data: {
|
||||
email: profileData.email,
|
||||
name: profileData.name,
|
||||
username: profileData.username,
|
||||
picture: profileData.picture,
|
||||
roles: profileData.roles,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should rethrow non-P2025 errors', async () => {
|
||||
const profileData = {
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const dbError = new Error('Database connection failed');
|
||||
mockPrismaService.user.update.mockRejectedValue(dbError);
|
||||
|
||||
await expect(
|
||||
service.syncProfileFromToken('f:realm:user123', profileData),
|
||||
).rejects.toThrow('Database connection failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchUsers', () => {
|
||||
const users: User[] = [
|
||||
{ ...mockUser, id: 'user1', username: 'alice' },
|
||||
@@ -760,7 +340,7 @@ describe('UsersService', () => {
|
||||
const result = await service.setActiveDoll(
|
||||
mockUser.id,
|
||||
dollId,
|
||||
mockUser.keycloakSub,
|
||||
mockUser.id,
|
||||
);
|
||||
|
||||
expect(result).toEqual(updatedUser);
|
||||
@@ -775,7 +355,7 @@ describe('UsersService', () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(
|
||||
service.setActiveDoll(mockUser.id, dollId, 'other-keycloak-sub'),
|
||||
service.setActiveDoll(mockUser.id, dollId, 'other-user-id'),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
@@ -788,7 +368,7 @@ describe('UsersService', () => {
|
||||
mockPrismaService.doll.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.setActiveDoll(mockUser.id, dollId, mockUser.keycloakSub),
|
||||
service.setActiveDoll(mockUser.id, dollId, mockUser.id),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
@@ -802,7 +382,7 @@ describe('UsersService', () => {
|
||||
mockPrismaService.doll.findUnique.mockResolvedValue(deletedDoll);
|
||||
|
||||
await expect(
|
||||
service.setActiveDoll(mockUser.id, dollId, mockUser.keycloakSub),
|
||||
service.setActiveDoll(mockUser.id, dollId, mockUser.id),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
@@ -816,7 +396,7 @@ describe('UsersService', () => {
|
||||
mockPrismaService.doll.findUnique.mockResolvedValue(otherUserDoll);
|
||||
|
||||
await expect(
|
||||
service.setActiveDoll(mockUser.id, dollId, mockUser.keycloakSub),
|
||||
service.setActiveDoll(mockUser.id, dollId, mockUser.id),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
@@ -828,10 +408,7 @@ describe('UsersService', () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser);
|
||||
|
||||
const result = await service.removeActiveDoll(
|
||||
mockUser.id,
|
||||
mockUser.keycloakSub,
|
||||
);
|
||||
const result = await service.removeActiveDoll(mockUser.id, mockUser.id);
|
||||
|
||||
expect(result).toEqual(updatedUser);
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
@@ -844,7 +421,7 @@ describe('UsersService', () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(
|
||||
service.removeActiveDoll(mockUser.id, 'other-keycloak-sub'),
|
||||
service.removeActiveDoll(mockUser.id, 'other-user-id'),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,51 +8,19 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { User, Prisma } from '@prisma/client';
|
||||
import type { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/client';
|
||||
import { UserEvents } from './events/user.events';
|
||||
|
||||
/**
|
||||
* Interface for creating a user from Keycloak token
|
||||
*/
|
||||
export interface CreateUserFromTokenDto {
|
||||
keycloakSub: string;
|
||||
export interface CreateLocalUserDto {
|
||||
email: string;
|
||||
name: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
roles?: string[];
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Users Service
|
||||
*
|
||||
* Manages user data synchronized from Keycloak OIDC using Prisma ORM.
|
||||
* Users are created automatically when they first authenticate via Keycloak.
|
||||
* Direct user creation is not allowed - users must authenticate via Keycloak first.
|
||||
*
|
||||
* ## Profile Data Ownership
|
||||
*
|
||||
* Keycloak is the single source of truth for authentication and profile data:
|
||||
* - `keycloakSub`: Managed by Keycloak (immutable identifier)
|
||||
* - `email`: Managed by Keycloak
|
||||
* - `name`: Managed by Keycloak
|
||||
* - `username`: Managed by Keycloak
|
||||
* - `picture`: Managed by Keycloak
|
||||
* - `roles`: Managed by Keycloak
|
||||
*
|
||||
* Local application data:
|
||||
* - `lastLoginAt`: Tracked locally for analytics
|
||||
*
|
||||
* ## Sync Strategy
|
||||
*
|
||||
* - On every login: Compares Keycloak data with local data
|
||||
* - If profile changed: Updates all fields (name, email, picture, roles)
|
||||
* - If profile unchanged: Only updates `lastLoginAt`
|
||||
* - Read operations are cheaper than writes in PostgreSQL
|
||||
* - Explicit sync: Call `syncProfileFromToken()` for force sync (webhooks, manual refresh)
|
||||
*
|
||||
* This optimizes performance by avoiding unnecessary writes while keeping
|
||||
* profile data in sync with Keycloak on every authentication.
|
||||
* Manages user data for local authentication using Prisma ORM.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
@@ -63,215 +31,11 @@ export class UsersService {
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Finds a user or creates one if they don't exist.
|
||||
* Optimized to minimize database operations:
|
||||
* - If user exists: Returns immediately (1 read, 0 writes)
|
||||
* - If user missing: Creates user (1 read, 1 write)
|
||||
*
|
||||
* Unlike createFromToken, this method does NOT update lastLoginAt for existing users.
|
||||
*
|
||||
* @param createDto - User data
|
||||
* @returns The user entity
|
||||
*/
|
||||
async findOrCreate(createDto: CreateUserFromTokenDto): Promise<User> {
|
||||
// 1. Try to find the user (Read)
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { keycloakSub: createDto.keycloakSub },
|
||||
});
|
||||
// Legacy Keycloak user creation removed in favor of local auth.
|
||||
|
||||
// 2. If found, return immediately without update
|
||||
if (existingUser) {
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
// 3. If not found, create (Write)
|
||||
// We handle creation directly here to avoid a second read that createFromToken would do
|
||||
const roles = createDto.roles || [];
|
||||
const now = new Date();
|
||||
|
||||
this.logger.log(`Creating new user from token: ${createDto.keycloakSub}`);
|
||||
|
||||
// Use upsert to handle race conditions safely
|
||||
return await this.prisma.user.upsert({
|
||||
where: { keycloakSub: createDto.keycloakSub },
|
||||
update: {
|
||||
email: createDto.email,
|
||||
name: createDto.name,
|
||||
username: createDto.username,
|
||||
picture: createDto.picture,
|
||||
roles,
|
||||
lastLoginAt: now,
|
||||
},
|
||||
create: {
|
||||
keycloakSub: createDto.keycloakSub,
|
||||
email: createDto.email,
|
||||
name: createDto.name,
|
||||
username: createDto.username,
|
||||
picture: createDto.picture,
|
||||
roles,
|
||||
lastLoginAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user or syncs/tracks login for existing users.
|
||||
* This method is called automatically during authentication flow.
|
||||
*
|
||||
* For new users: Creates the user with full profile data from token.
|
||||
* For existing users: Compares Keycloak data with local data and only updates if changed.
|
||||
* This optimizes performance since reads are cheaper than writes in PostgreSQL.
|
||||
*
|
||||
* @param createDto - User data extracted from Keycloak JWT token
|
||||
* @returns The user entity
|
||||
*/
|
||||
async createFromToken(createDto: CreateUserFromTokenDto): Promise<User> {
|
||||
// Normalize roles once to avoid duplication
|
||||
const roles = createDto.roles || [];
|
||||
const now = new Date();
|
||||
|
||||
// Check if user exists first (read is cheaper than write)
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { keycloakSub: createDto.keycloakSub },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// Compare profile data to detect changes
|
||||
const profileChanged =
|
||||
existingUser.email !== createDto.email ||
|
||||
existingUser.name !== createDto.name ||
|
||||
existingUser.username !== createDto.username ||
|
||||
existingUser.picture !== createDto.picture ||
|
||||
JSON.stringify(existingUser.roles) !== JSON.stringify(roles);
|
||||
|
||||
if (profileChanged) {
|
||||
// Profile data changed - update everything
|
||||
this.logger.debug(
|
||||
`Profile changed for user: ${existingUser.id} (${createDto.keycloakSub})`,
|
||||
);
|
||||
return await this.prisma.user.update({
|
||||
where: { keycloakSub: createDto.keycloakSub },
|
||||
data: {
|
||||
email: createDto.email,
|
||||
name: createDto.name,
|
||||
username: createDto.username,
|
||||
picture: createDto.picture,
|
||||
roles,
|
||||
lastLoginAt: now,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Profile unchanged - only update lastLoginAt
|
||||
this.logger.debug(
|
||||
`Login tracked for user: ${existingUser.id} (${createDto.keycloakSub})`,
|
||||
);
|
||||
return await this.prisma.user.update({
|
||||
where: { keycloakSub: createDto.keycloakSub },
|
||||
data: {
|
||||
lastLoginAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// New user - create with all profile data
|
||||
// Use upsert to handle race condition if user was created between findUnique and here
|
||||
this.logger.log(`Creating new user from token: ${createDto.keycloakSub}`);
|
||||
const user = await this.prisma.user.upsert({
|
||||
where: { keycloakSub: createDto.keycloakSub },
|
||||
update: {
|
||||
// If created by concurrent request, update with current data
|
||||
email: createDto.email,
|
||||
name: createDto.name,
|
||||
username: createDto.username,
|
||||
picture: createDto.picture,
|
||||
roles,
|
||||
lastLoginAt: now,
|
||||
},
|
||||
create: {
|
||||
keycloakSub: createDto.keycloakSub,
|
||||
email: createDto.email,
|
||||
name: createDto.name,
|
||||
username: createDto.username,
|
||||
picture: createDto.picture,
|
||||
roles,
|
||||
lastLoginAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force syncs user profile data from Keycloak token.
|
||||
* This should be called when explicit profile sync is needed.
|
||||
*
|
||||
* Use cases:
|
||||
* - Keycloak webhook notification of profile change
|
||||
* - Manual profile refresh request
|
||||
* - Administrative profile sync operations
|
||||
*
|
||||
* Note: createFromToken() already handles profile sync on login automatically.
|
||||
* This method is for explicit, out-of-band sync operations.
|
||||
*
|
||||
* @param keycloakSub - The Keycloak subject identifier
|
||||
* @param profileData - Profile data from Keycloak token
|
||||
* @returns The updated user
|
||||
* @throws NotFoundException if the user is not found
|
||||
*/
|
||||
async syncProfileFromToken(
|
||||
keycloakSub: string,
|
||||
profileData: Omit<CreateUserFromTokenDto, 'keycloakSub'>,
|
||||
): Promise<User> {
|
||||
// Normalize roles once
|
||||
const roles = profileData.roles || [];
|
||||
|
||||
try {
|
||||
const updatedUser = await this.prisma.user.update({
|
||||
where: { keycloakSub },
|
||||
data: {
|
||||
email: profileData.email,
|
||||
name: profileData.name,
|
||||
username: profileData.username,
|
||||
picture: profileData.picture,
|
||||
roles,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Profile synced from Keycloak for user: ${updatedUser.id} (${keycloakSub})`,
|
||||
);
|
||||
|
||||
return updatedUser;
|
||||
} catch (error) {
|
||||
// Prisma throws P2025 when record is not found
|
||||
if (
|
||||
error instanceof PrismaClientKnownRequestError &&
|
||||
error.code === 'P2025'
|
||||
) {
|
||||
throw new NotFoundException(
|
||||
`User with keycloakSub ${keycloakSub} not found`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a user by their Keycloak subject identifier.
|
||||
*
|
||||
* @param keycloakSub - The Keycloak subject (sub claim from JWT)
|
||||
* @returns The user if found, null otherwise
|
||||
*/
|
||||
async findByKeycloakSub(keycloakSub: string): Promise<User | null> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { keycloakSub },
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
// Legacy Keycloak sync logic removed in favor of local auth.
|
||||
|
||||
// Legacy Keycloak sync docs removed in favor of local auth.
|
||||
/**
|
||||
* Finds a user by their internal ID.
|
||||
*
|
||||
@@ -293,12 +57,12 @@ export class UsersService {
|
||||
|
||||
/**
|
||||
* Updates a user's profile.
|
||||
* Currently, all profile fields are managed by Keycloak and cannot be updated locally.
|
||||
* This method exists for future extensibility if local profile fields are added.
|
||||
* Currently, no profile fields are updatable locally.
|
||||
* This method exists for future extensibility if local fields are added.
|
||||
*
|
||||
* @param id - The user's internal ID
|
||||
* @param updateUserDto - The fields to update (currently none supported)
|
||||
* @param requestingUserKeycloakSub - The Keycloak sub of the requesting user
|
||||
* @param requestingUserId - The requesting user id
|
||||
* @returns The updated user
|
||||
* @throws NotFoundException if the user is not found
|
||||
* @throws ForbiddenException if the user tries to update someone else's profile
|
||||
@@ -306,14 +70,14 @@ export class UsersService {
|
||||
async update(
|
||||
id: string,
|
||||
updateUserDto: UpdateUserDto,
|
||||
requestingUserKeycloakSub: string,
|
||||
requestingUserId: string,
|
||||
): Promise<User> {
|
||||
const user = await this.findOne(id);
|
||||
|
||||
// Verify the user is updating their own profile
|
||||
if (user.keycloakSub !== requestingUserKeycloakSub) {
|
||||
if (user.id !== requestingUserId) {
|
||||
this.logger.warn(
|
||||
`User ${requestingUserKeycloakSub} attempted to update user ${id}`,
|
||||
`User ${requestingUserId} attempted to update user ${id}`,
|
||||
);
|
||||
throw new ForbiddenException('You can only update your own profile');
|
||||
}
|
||||
@@ -342,12 +106,12 @@ export class UsersService {
|
||||
* @throws NotFoundException if the user is not found
|
||||
* @throws ForbiddenException if the user tries to delete someone else's account
|
||||
*/
|
||||
async delete(id: string, requestingUserKeycloakSub: string): Promise<void> {
|
||||
async delete(id: string, requestingUserId: string): Promise<void> {
|
||||
const user = await this.findOne(id);
|
||||
|
||||
if (user.keycloakSub !== requestingUserKeycloakSub) {
|
||||
if (user.id !== requestingUserId) {
|
||||
this.logger.warn(
|
||||
`User ${requestingUserKeycloakSub} attempted to delete user ${id}`,
|
||||
`User ${requestingUserId} attempted to delete user ${id}`,
|
||||
);
|
||||
throw new ForbiddenException('You can only delete your own account');
|
||||
}
|
||||
@@ -356,9 +120,7 @@ export class UsersService {
|
||||
where: { id },
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`User ${id} deleted their account (Keycloak: ${requestingUserKeycloakSub})`,
|
||||
);
|
||||
this.logger.log(`User ${id} deleted their account`);
|
||||
}
|
||||
|
||||
async searchUsers(
|
||||
@@ -403,12 +165,12 @@ export class UsersService {
|
||||
async setActiveDoll(
|
||||
userId: string,
|
||||
dollId: string,
|
||||
requestingUserKeycloakSub: string,
|
||||
requestingUserId: string,
|
||||
): Promise<User> {
|
||||
const user = await this.findOne(userId);
|
||||
|
||||
// Verify the user is updating their own profile
|
||||
if (user.keycloakSub !== requestingUserKeycloakSub) {
|
||||
if (user.id !== requestingUserId) {
|
||||
throw new ForbiddenException('You can only update your own profile');
|
||||
}
|
||||
|
||||
@@ -452,12 +214,12 @@ export class UsersService {
|
||||
*/
|
||||
async removeActiveDoll(
|
||||
userId: string,
|
||||
requestingUserKeycloakSub: string,
|
||||
requestingUserId: string,
|
||||
): Promise<User> {
|
||||
const user = await this.findOne(userId);
|
||||
|
||||
// Verify the user is updating their own profile
|
||||
if (user.keycloakSub !== requestingUserKeycloakSub) {
|
||||
if (user.id !== requestingUserId) {
|
||||
throw new ForbiddenException('You can only update your own profile');
|
||||
}
|
||||
|
||||
@@ -477,4 +239,42 @@ export class UsersService {
|
||||
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findFirst({ where: { email } });
|
||||
}
|
||||
|
||||
async createLocalUser(createDto: CreateLocalUserDto): Promise<User> {
|
||||
const now = new Date();
|
||||
const roles: string[] = [];
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
email: createDto.email,
|
||||
name: createDto.name,
|
||||
username: createDto.username,
|
||||
passwordHash: createDto.passwordHash,
|
||||
roles,
|
||||
lastLoginAt: now,
|
||||
keycloakSub: null,
|
||||
} as unknown as Prisma.UserUncheckedCreateInput,
|
||||
});
|
||||
}
|
||||
|
||||
async updatePasswordHash(
|
||||
userId: string,
|
||||
passwordHash: string,
|
||||
): Promise<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash } as unknown as Prisma.UserUpdateInput,
|
||||
});
|
||||
}
|
||||
|
||||
async updateLastLogin(userId: string): Promise<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { CursorPositionDto } from '../dto/cursor-position.dto';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { StateGateway } from './state.gateway';
|
||||
import { AuthenticatedSocket } from '../../types/socket';
|
||||
import { AuthService } from '../../auth/auth.service';
|
||||
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { UserSocketService } from './user-socket.service';
|
||||
import { WsNotificationService } from './ws-notification.service';
|
||||
@@ -12,15 +12,13 @@ import { WsException } from '@nestjs/websockets';
|
||||
|
||||
import { UserStatusDto, UserState } from '../dto/user-status.dto';
|
||||
|
||||
interface MockSocket extends Partial<AuthenticatedSocket> {
|
||||
type MockSocket = {
|
||||
id: string;
|
||||
data: {
|
||||
user?: {
|
||||
keycloakSub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
picture?: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
roles?: string[];
|
||||
};
|
||||
userId?: string;
|
||||
activeDollId?: string | null;
|
||||
@@ -29,7 +27,7 @@ interface MockSocket extends Partial<AuthenticatedSocket> {
|
||||
handshake?: any;
|
||||
disconnect?: jest.Mock;
|
||||
emit?: jest.Mock;
|
||||
}
|
||||
};
|
||||
|
||||
describe('StateGateway', () => {
|
||||
let gateway: StateGateway;
|
||||
@@ -41,7 +39,7 @@ describe('StateGateway', () => {
|
||||
sockets: { sockets: { size: number; get: jest.Mock } };
|
||||
to: jest.Mock;
|
||||
};
|
||||
let mockAuthService: Partial<AuthService>;
|
||||
let mockUsersService: Partial<UsersService>;
|
||||
let mockJwtVerificationService: Partial<JwtVerificationService>;
|
||||
let mockPrismaService: Partial<PrismaService>;
|
||||
let mockUserSocketService: Partial<UserSocketService>;
|
||||
@@ -69,16 +67,15 @@ describe('StateGateway', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
mockAuthService = {
|
||||
syncUserFromToken: jest.fn().mockResolvedValue({
|
||||
mockUsersService = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 'user-id',
|
||||
keycloakSub: 'test-sub',
|
||||
}),
|
||||
};
|
||||
|
||||
mockJwtVerificationService = {
|
||||
extractToken: jest.fn((handshake) => handshake.auth?.token),
|
||||
verifyToken: jest.fn().mockResolvedValue({
|
||||
verifyToken: jest.fn().mockReturnValue({
|
||||
sub: 'test-sub',
|
||||
email: 'test@example.com',
|
||||
}),
|
||||
@@ -122,7 +119,7 @@ describe('StateGateway', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
StateGateway,
|
||||
{ provide: AuthService, useValue: mockAuthService },
|
||||
{ provide: UsersService, useValue: mockUsersService },
|
||||
{
|
||||
provide: JwtVerificationService,
|
||||
useValue: mockJwtVerificationService,
|
||||
@@ -172,7 +169,7 @@ describe('StateGateway', () => {
|
||||
});
|
||||
|
||||
describe('handleConnection', () => {
|
||||
it('should verify token and set basic user data (but NOT sync DB)', async () => {
|
||||
it('should verify token and set basic user data (but NOT sync DB)', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {},
|
||||
@@ -183,9 +180,7 @@ describe('StateGateway', () => {
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
|
||||
await gateway.handleConnection(
|
||||
mockClient as unknown as AuthenticatedSocket,
|
||||
);
|
||||
gateway.handleConnection(mockClient as unknown as AuthenticatedSocket);
|
||||
|
||||
expect(mockJwtVerificationService.extractToken).toHaveBeenCalledWith(
|
||||
mockClient.handshake,
|
||||
@@ -195,13 +190,13 @@ describe('StateGateway', () => {
|
||||
);
|
||||
|
||||
// Should NOT call these anymore in handleConnection
|
||||
expect(mockAuthService.syncUserFromToken).not.toHaveBeenCalled();
|
||||
expect(mockUsersService.findOne).not.toHaveBeenCalled();
|
||||
expect(mockUserSocketService.setSocket).not.toHaveBeenCalled();
|
||||
|
||||
// Should set data on client
|
||||
expect(mockClient.data.user).toEqual(
|
||||
expect.objectContaining({
|
||||
keycloakSub: 'test-sub',
|
||||
userId: 'test-sub',
|
||||
}),
|
||||
);
|
||||
expect(mockClient.data.activeDollId).toBeNull();
|
||||
@@ -211,7 +206,7 @@ describe('StateGateway', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should disconnect client when no token provided', async () => {
|
||||
it('should disconnect client when no token provided', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {},
|
||||
@@ -226,9 +221,7 @@ describe('StateGateway', () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
await gateway.handleConnection(
|
||||
mockClient as unknown as AuthenticatedSocket,
|
||||
);
|
||||
gateway.handleConnection(mockClient as unknown as AuthenticatedSocket);
|
||||
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
'WebSocket connection attempt without token',
|
||||
@@ -242,7 +235,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
friends: new Set(),
|
||||
},
|
||||
emit: jest.fn(),
|
||||
@@ -262,10 +255,8 @@ describe('StateGateway', () => {
|
||||
mockClient as unknown as AuthenticatedSocket,
|
||||
);
|
||||
|
||||
// 1. Sync User
|
||||
expect(mockAuthService.syncUserFromToken).toHaveBeenCalledWith(
|
||||
mockClient.data.user,
|
||||
);
|
||||
// 1. Load User
|
||||
expect(mockUsersService.findOne).toHaveBeenCalledWith('test-sub');
|
||||
|
||||
// 2. Set Socket
|
||||
expect(mockUserSocketService.setSocket).toHaveBeenCalledWith(
|
||||
@@ -320,7 +311,7 @@ describe('StateGateway', () => {
|
||||
it('should log client disconnection', async () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: { user: { keycloakSub: 'test-sub' } },
|
||||
data: { user: { userId: 'test-sub', email: 'test@example.com' } },
|
||||
};
|
||||
|
||||
await gateway.handleDisconnect(
|
||||
@@ -351,7 +342,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-id',
|
||||
friends: new Set(['friend-1']),
|
||||
},
|
||||
@@ -385,7 +376,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
activeDollId: 'doll-1', // User must have active doll
|
||||
friends: new Set(['friend-1']),
|
||||
@@ -422,7 +413,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
activeDollId: null, // No doll
|
||||
friends: new Set(['friend-1']),
|
||||
@@ -443,7 +434,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
// userId is missing
|
||||
friends: new Set(['friend-1']),
|
||||
},
|
||||
@@ -482,7 +473,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
activeDollId: 'doll-1', // User must have active doll
|
||||
friends: new Set(['friend-1']),
|
||||
@@ -523,7 +514,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
activeDollId: null, // No doll
|
||||
friends: new Set(['friend-1']),
|
||||
@@ -551,7 +542,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
// userId is missing
|
||||
friends: new Set(['friend-1']),
|
||||
},
|
||||
@@ -601,7 +592,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
activeDollId: 'doll-1',
|
||||
friends: new Set(['friend-1']),
|
||||
@@ -652,7 +643,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub', name: 'TestUser' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
friends: new Set(['friend-1']),
|
||||
},
|
||||
@@ -677,7 +668,6 @@ describe('StateGateway', () => {
|
||||
'interaction-received',
|
||||
expect.objectContaining({
|
||||
senderUserId: 'user-1',
|
||||
senderName: 'TestUser',
|
||||
content: 'hello',
|
||||
type: 'text',
|
||||
}),
|
||||
@@ -688,7 +678,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
friends: new Set(['friend-1']),
|
||||
},
|
||||
@@ -719,7 +709,7 @@ describe('StateGateway', () => {
|
||||
const mockClient: MockSocket = {
|
||||
id: 'client1',
|
||||
data: {
|
||||
user: { keycloakSub: 'test-sub' },
|
||||
user: { userId: 'test-sub', email: 'test@example.com' },
|
||||
userId: 'user-1',
|
||||
friends: new Set(['friend-1']),
|
||||
},
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
REDIS_SUBSCRIBER_CLIENT,
|
||||
} from '../../database/redis.module';
|
||||
import type { AuthenticatedSocket } from '../../types/socket';
|
||||
import { AuthService } from '../../auth/auth.service';
|
||||
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
||||
import { CursorPositionDto } from '../dto/cursor-position.dto';
|
||||
import { UserStatusDto } from '../dto/user-status.dto';
|
||||
@@ -25,6 +24,7 @@ import { PrismaService } from '../../database/prisma.service';
|
||||
import { UserSocketService } from './user-socket.service';
|
||||
import { WsNotificationService } from './ws-notification.service';
|
||||
import { WS_EVENT, REDIS_CHANNEL } from './ws-events';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
|
||||
const USER_STATUS_BROADCAST_THROTTLING_MS = 200;
|
||||
|
||||
@@ -43,9 +43,9 @@ export class StateGateway
|
||||
@WebSocketServer() io: Server;
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly jwtVerificationService: JwtVerificationService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly userSocketService: UserSocketService,
|
||||
private readonly wsNotificationService: WsNotificationService,
|
||||
@Inject(REDIS_CLIENT) private readonly redisClient: Redis | null,
|
||||
@@ -118,7 +118,7 @@ export class StateGateway
|
||||
}
|
||||
}
|
||||
|
||||
async handleConnection(client: AuthenticatedSocket) {
|
||||
handleConnection(client: AuthenticatedSocket) {
|
||||
try {
|
||||
this.logger.debug(
|
||||
`Connection attempt - handshake auth: ${JSON.stringify(client.handshake.auth)}`,
|
||||
@@ -135,18 +135,16 @@ export class StateGateway
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await this.jwtVerificationService.verifyToken(token);
|
||||
const payload = this.jwtVerificationService.verifyToken(token);
|
||||
|
||||
if (!payload.sub) {
|
||||
throw new WsException('Invalid token: missing subject');
|
||||
}
|
||||
|
||||
client.data.user = {
|
||||
keycloakSub: payload.sub,
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
username: payload.preferred_username,
|
||||
picture: payload.picture,
|
||||
roles: payload.roles,
|
||||
};
|
||||
|
||||
// Initialize defaults
|
||||
@@ -186,17 +184,15 @@ export class StateGateway
|
||||
throw new WsException('Unauthorized: No user data found');
|
||||
}
|
||||
|
||||
const payload = await this.jwtVerificationService.verifyToken(token);
|
||||
const payload = this.jwtVerificationService.verifyToken(token);
|
||||
if (!payload.sub) {
|
||||
throw new WsException('Invalid token: missing subject');
|
||||
}
|
||||
|
||||
userTokenData = {
|
||||
keycloakSub: payload.sub,
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
username: payload.preferred_username,
|
||||
picture: payload.picture,
|
||||
roles: payload.roles,
|
||||
};
|
||||
client.data.user = userTokenData;
|
||||
|
||||
@@ -209,8 +205,7 @@ export class StateGateway
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Sync user from token (DB Write/Read)
|
||||
const user = await this.authService.syncUserFromToken(userTokenData);
|
||||
const user = await this.usersService.findOne(userTokenData.userId);
|
||||
|
||||
// 2. Register socket mapping (Redis Write)
|
||||
await this.userSocketService.setSocket(user.id, client.id);
|
||||
@@ -283,7 +278,7 @@ export class StateGateway
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Client id: ${client.id} disconnected (user: ${user?.keycloakSub || 'unknown'})`,
|
||||
`Client id: ${client.id} disconnected (user: ${user?.userId || 'unknown'})`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -438,9 +433,15 @@ export class StateGateway
|
||||
}
|
||||
|
||||
// 3. Construct payload
|
||||
const sender = await this.prisma.user.findUnique({
|
||||
where: { id: currentUserId },
|
||||
select: { name: true, username: true },
|
||||
});
|
||||
const senderName = sender?.name || sender?.username || 'Unknown';
|
||||
|
||||
const payload: InteractionPayloadDto = {
|
||||
senderUserId: currentUserId,
|
||||
senderName: user.name || user.username || 'Unknown',
|
||||
senderName,
|
||||
content: data.content,
|
||||
type: data.type,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@@ -3,11 +3,17 @@ import { StateGateway } from './state/state.gateway';
|
||||
import { WsNotificationService } from './state/ws-notification.service';
|
||||
import { UserSocketService } from './state/user-socket.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { FriendsModule } from '../friends/friends.module';
|
||||
import { RedisModule } from '../database/redis.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, RedisModule, forwardRef(() => FriendsModule)],
|
||||
imports: [
|
||||
AuthModule,
|
||||
forwardRef(() => UsersModule),
|
||||
RedisModule,
|
||||
forwardRef(() => FriendsModule),
|
||||
],
|
||||
providers: [StateGateway, WsNotificationService, UserSocketService],
|
||||
exports: [StateGateway, WsNotificationService, UserSocketService],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user