native auth

This commit is contained in:
2026-02-11 01:09:08 +08:00
parent 7191035748
commit 94dae77ddd
34 changed files with 650 additions and 1801 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
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;
async register(data: {
email: string;
password: string;
name?: string;
username?: string;
}): Promise<User> {
const { email, password, name, username } = data;
const user = await this.usersService.createFromToken({
keycloakSub,
email: email || '',
const existing = await this.usersService.findByEmail(email);
if (existing) {
throw new BadRequestException('Email already registered');
}
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;
}

View File

@@ -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 };
* }
* ```
*/

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

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

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

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

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

View File

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

View File

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

View File

@@ -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,
{
issuer: this.issuer,
audience: this.audience,
algorithms: [JWT_ALGORITHM],
},
(err, decoded) => {
if (err) {
reject(err);
return;
}
resolve(decoded as JwtPayload);
},
);
});
verifyToken(token: string): JwtPayload {
return verify(token, this.jwtSecret, {
issuer: this.issuer,
audience: this.audience,
algorithms: [JWT_ALGORITHM],
}) as JwtPayload;
}
extractToken(handshake: {

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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