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 REDIS_PORT=6379
# JWT Configuration # JWT Configuration
# Keycloak realm URL (no trailing slash). Example: https://keycloak.example.com/realms/friendolls JWT_SECRET=replace-with-strong-random-secret
JWT_ISSUER=https://your-keycloak-instance.com/auth/realms/your-realm-name JWT_ISSUER=friendolls
# The expected audience in the JWT token (usually the client ID for this API)
JWT_AUDIENCE=friendolls-api JWT_AUDIENCE=friendolls-api
JWT_EXPIRES_IN_SECONDS=3600
# Keycloak client used for access tokens # Temporary migration flow (remove after migration)
KEYCLOAK_CLIENT_ID=friendolls-api ALLOW_LEGACY_PASSWORD=true
# 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

View File

@@ -45,8 +45,8 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"axios": "^1.7.9", "axios": "^1.7.9",
"bcryptjs": "^3.0.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.16.3", "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) 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': '@nestjs/event-emitter':
specifier: ^3.0.1 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': '@nestjs/passport':
specifier: ^11.0.5 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) 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) 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': '@nestjs/swagger':
specifier: ^11.2.3 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': '@nestjs/throttler':
specifier: ^6.5.0 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': '@nestjs/websockets':
specifier: ^11.1.9 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) 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: axios:
specifier: ^1.7.9 specifier: ^1.7.9
version: 1.13.2 version: 1.13.2
bcryptjs:
specifier: ^3.0.2
version: 3.0.3
class-transformer: class-transformer:
specifier: ^0.5.1 specifier: ^0.5.1
version: 0.5.1 version: 0.5.1
@@ -65,9 +68,6 @@ importers:
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.2 specifier: ^9.0.2
version: 9.0.2 version: 9.0.2
jwks-rsa:
specifier: ^3.2.0
version: 3.2.0
passport: passport:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
@@ -101,7 +101,7 @@ importers:
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
'@nestjs/testing': '@nestjs/testing':
specifier: ^11.0.1 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': '@types/express':
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.5 version: 5.0.5
@@ -1071,15 +1071,9 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 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': '@types/express-serve-static-core@5.1.0':
resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==} resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==}
'@types/express@4.17.25':
resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==}
'@types/express@5.0.5': '@types/express@5.0.5':
resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==} resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==}
@@ -1539,6 +1533,10 @@ packages:
resolution: {integrity: sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==} resolution: {integrity: sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==}
hasBin: true hasBin: true
bcryptjs@3.0.3:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
bl@4.1.0: bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@@ -2548,9 +2546,6 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2600,10 +2595,6 @@ packages:
jwa@1.4.2: jwa@1.4.2:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} 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: jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
@@ -2625,9 +2616,6 @@ packages:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
limiter@1.1.5:
resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==}
lines-and-columns@1.2.4: lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -2647,9 +2635,6 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
lodash.defaults@4.2.0: lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
@@ -2703,17 +2688,10 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} 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: lru-cache@7.18.3:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'} engines: {node: '>=12'}
lru-memoizer@2.3.0:
resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==}
lru.min@1.1.3: lru.min@1.1.3:
resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==} resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
@@ -3749,9 +3727,6 @@ packages:
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yargs-parser@21.1.1: yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'} 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/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/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: 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/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/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: transitivePeerDependencies:
- chokidar - 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: dependencies:
'@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc': 0.16.0
'@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/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-transformer: 0.5.1
class-validator: 0.14.2 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: 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/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/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: 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/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: 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/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/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/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': '@types/express-serve-static-core@5.1.0':
dependencies: dependencies:
'@types/node': 22.19.1 '@types/node': 22.19.1
@@ -4905,13 +4873,6 @@ snapshots:
'@types/range-parser': 1.2.7 '@types/range-parser': 1.2.7
'@types/send': 1.2.1 '@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': '@types/express@5.0.5':
dependencies: dependencies:
'@types/body-parser': 1.19.6 '@types/body-parser': 1.19.6
@@ -5423,6 +5384,8 @@ snapshots:
baseline-browser-mapping@2.8.30: {} baseline-browser-mapping@2.8.30: {}
bcryptjs@3.0.3: {}
bl@4.1.0: bl@4.1.0:
dependencies: dependencies:
buffer: 5.7.1 buffer: 5.7.1
@@ -6648,8 +6611,6 @@ snapshots:
jiti@2.6.1: {} jiti@2.6.1: {}
jose@4.15.9: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
js-yaml@3.14.2: js-yaml@3.14.2:
@@ -6702,17 +6663,6 @@ snapshots:
ecdsa-sig-formatter: 1.0.11 ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1 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: jws@3.2.2:
dependencies: dependencies:
jwa: 1.4.2 jwa: 1.4.2
@@ -6733,8 +6683,6 @@ snapshots:
lilconfig@2.1.0: {} lilconfig@2.1.0: {}
limiter@1.1.5: {}
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
load-esm@1.0.3: {} load-esm@1.0.3: {}
@@ -6749,8 +6697,6 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.clonedeep@4.5.0: {}
lodash.defaults@4.2.0: {} lodash.defaults@4.2.0: {}
lodash.includes@4.3.0: {} lodash.includes@4.3.0: {}
@@ -6790,17 +6736,8 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
lru-cache@7.18.3: {} 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: {} lru.min@1.1.3: {}
magic-string@0.30.17: magic-string@0.30.17:
@@ -7819,8 +7756,6 @@ snapshots:
yallist@3.1.1: {} yallist@3.1.1: {}
yallist@4.0.0: {}
yargs-parser@21.1.1: {} yargs-parser@21.1.1: {}
yargs@17.7.2: 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" provider = "postgresql"
} }
/// User model representing authenticated users from Keycloak OIDC /// User model representing authenticated users from local auth
model User { model User {
/// Internal unique identifier (UUID) /// Internal unique identifier (UUID)
id String @id @default(uuid()) id String @id @default(uuid())
/// Keycloak subject identifier (unique per user in Keycloak) /// Keycloak subject identifier (legacy for migration)
/// This is the 'sub' claim from the JWT token /// This is the 'sub' claim from the old JWT token
keycloakSub String @unique @map("keycloak_sub") keycloakSub String? @unique @map("keycloak_sub")
/// User's display name /// User's display name
name String name String
/// User's email address /// User's email address
email String email String @unique
/// User's preferred username from Keycloak /// User's preferred username from Keycloak
username String? username String?
@@ -33,6 +33,9 @@ model User {
/// User's roles from Keycloak (stored as JSON array) /// User's roles from Keycloak (stored as JSON array)
roles String[] roles String[]
/// Password hash for local authentication
passwordHash String? @map("password_hash")
/// Timestamp when the user was first created in the system /// Timestamp when the user was first created in the system
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")

View File

@@ -18,12 +18,7 @@ import { DollsModule } from './dolls/dolls.module';
* Returns the validated config. * Returns the validated config.
*/ */
function validateEnvironment(config: Record<string, any>): Record<string, any> { function validateEnvironment(config: Record<string, any>): Record<string, any> {
const requiredVars = [ const requiredVars = ['JWT_SECRET', 'DATABASE_URL'];
'JWKS_URI',
'JWT_ISSUER',
'JWT_AUDIENCE',
'DATABASE_URL',
];
const missingVars = requiredVars.filter((varName) => !config[varName]); 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 { JwtStrategy } from './strategies/jwt.strategy';
import { JwtVerificationService } from './services/jwt-verification.service'; import { JwtVerificationService } from './services/jwt-verification.service';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
@Module({ @Module({
imports: [ imports: [
@@ -12,6 +13,7 @@ import { UsersModule } from '../users/users.module';
PassportModule.register({ defaultStrategy: 'jwt' }), PassportModule.register({ defaultStrategy: 'jwt' }),
forwardRef(() => UsersModule), forwardRef(() => UsersModule),
], ],
controllers: [AuthController],
providers: [JwtStrategy, AuthService, JwtVerificationService], providers: [JwtStrategy, AuthService, JwtVerificationService],
exports: [AuthService, PassportModule, 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 { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios'; import { sign } from 'jsonwebtoken';
import { URLSearchParams } from 'url'; import { compare, hash } from 'bcryptjs';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import type { AuthenticatedUser } from './decorators/current-user.decorator';
import { User } from '../users/users.entity'; import { User } from '../users/users.entity';
import type { AuthenticatedUser } from './decorators/current-user.decorator';
/** /**
* Authentication Service * Authentication Service
* *
* Handles authentication-related business logic including: * Handles native authentication:
* - User login tracking from Keycloak tokens * - User registration
* - Profile synchronization from Keycloak * - Login with email/password
* - Role-based authorization checks * - JWT issuance
* * - Password changes
* ## 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
*/ */
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name); private readonly logger = new Logger(AuthService.name);
private readonly jwtSecret: string;
private readonly jwtIssuer: string;
private readonly jwtAudience?: string;
private readonly jwtExpiresInSeconds: number;
constructor( constructor(
private readonly usersService: UsersService, private readonly usersService: UsersService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) {
this.jwtSecret = this.configService.get<string>('JWT_SECRET') || '';
/** if (!this.jwtSecret) {
* Revoke refresh token via Keycloak token revocation endpoint, if configured. throw new Error('JWT_SECRET must be configured');
* Returns true on success; false on missing config or failure. }
*/ this.jwtIssuer =
async revokeToken(refreshToken: string): Promise<boolean> { this.configService.get<string>('JWT_ISSUER') || 'friendolls';
const issuer = this.configService.get<string>('JWT_ISSUER'); this.jwtAudience = this.configService.get<string>('JWT_AUDIENCE');
const clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID'); this.jwtExpiresInSeconds = Number(
const clientSecret = this.configService.get<string>( this.configService.get<string>('JWT_EXPIRES_IN_SECONDS') || '3600',
'KEYCLOAK_CLIENT_SECRET',
); );
if (!issuer || !clientId) {
this.logger.warn(
'JWT issuer or client id missing, skipping token revocation',
);
return false;
} }
const revokeUrl = `${issuer}/protocol/openid-connect/revoke`; async register(data: {
try { email: string;
const params = new URLSearchParams({ password: string;
client_id: clientId, name?: string;
token: refreshToken, username?: string;
token_type_hint: 'refresh_token', }): Promise<User> {
}); const { email, password, name, username } = data;
if (clientSecret) {
params.set('client_secret', clientSecret); const existing = await this.usersService.findByEmail(email);
if (existing) {
throw new BadRequestException('Email already registered');
} }
const response = await axios.post(revokeUrl, params, { const passwordHash = await hash(password, 12);
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, return this.usersService.createLocalUser({
timeout: 5000, email,
validateStatus: (status) => status >= 200 && status < 500, passwordHash,
});
if (response.status >= 200 && response.status < 300) {
this.logger.log('Refresh token revoked');
return true;
}
this.logger.warn(
`Token revocation failed with status ${response.status}`,
);
return false;
} catch (error) {
const err = error as AxiosError;
this.logger.warn(
`Failed to revoke token: ${err.response?.status} ${err.response?.statusText ?? err.message}`,
);
return false;
}
}
/**
* Handles user login and profile synchronization from Keycloak token.
* Creates new users with full profile data.
* For existing users, intelligently syncs only changed fields to optimize performance.
*
* The service compares Keycloak data with local database and:
* - Updates profile fields only if they changed from Keycloak
* - Always updates lastLoginAt to track login activity
*
* @param authenticatedUser - User data extracted from JWT token
* @returns The user entity
*/
async syncUserFromToken(authenticatedUser: AuthenticatedUser): Promise<User> {
const { keycloakSub, email, name, username, picture, roles } =
authenticatedUser;
const user = await this.usersService.createFromToken({
keycloakSub,
email: email || '',
name: name || username || 'Unknown User', name: name || username || 'Unknown User',
username, 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; 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) { if (!user.roles || user.roles.length === 0) {
return false; return false;
} }
return requiredRoles.some((role) => user.roles!.includes(role)); 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) { if (!user.roles || user.roles.length === 0) {
return false; return false;
} }

View File

@@ -5,13 +5,9 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common';
* This matches the object returned by JwtStrategy.validate() * This matches the object returned by JwtStrategy.validate()
*/ */
export interface AuthenticatedUser { export interface AuthenticatedUser {
keycloakSub: string; userId: string;
email?: string; email: string;
name?: string;
username?: string;
picture?: string;
roles?: string[]; roles?: string[];
sessionState?: string;
} }
/** /**
@@ -34,8 +30,8 @@ export interface AuthenticatedUser {
* ```typescript * ```typescript
* @Get('profile') * @Get('profile')
* @UseGuards(JwtAuthGuard) * @UseGuards(JwtAuthGuard)
* async getProfile(@CurrentUser('keycloakSub') sub: string) { * async getProfile(@CurrentUser('userId') userId: string) {
* return { sub }; * 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 { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import type { Request } from 'express'; import type { Request } from 'express';
import { User } from 'src/users/users.entity'; import type { AuthenticatedUser } from '../decorators/current-user.decorator';
/** /**
* JWT Authentication Guard * 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. * It uses the JwtStrategy to validate the token and attach user info to the request.
* *
* Usage: * Usage:
@@ -57,7 +57,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest( handleRequest(
err: any, err: any,
user: User, user: AuthenticatedUser,
info: any, info: any,
context: ExecutionContext, context: ExecutionContext,
status?: any, status?: any,
@@ -82,7 +82,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
} }
} else { } else {
this.logger.debug( 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 = { const mockConfigService = {
get: jest.fn((key: string) => { get: jest.fn((key: string) => {
const config: Record<string, 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_ISSUER: 'https://test.com',
JWT_AUDIENCE: 'test-audience', JWT_AUDIENCE: 'test-audience',
}; };

View File

@@ -1,78 +1,36 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { verify, type JwtHeader } from 'jsonwebtoken'; import { verify } from 'jsonwebtoken';
import { JwksClient, type SigningKey } from 'jwks-rsa';
import type { JwtPayload } from '../strategies/jwt.strategy'; import type { JwtPayload } from '../strategies/jwt.strategy';
const JWT_ALGORITHM = 'RS256'; const JWT_ALGORITHM = 'HS256';
const BEARER_PREFIX = 'Bearer '; const BEARER_PREFIX = 'Bearer ';
@Injectable() @Injectable()
export class JwtVerificationService { export class JwtVerificationService {
private readonly logger = new Logger(JwtVerificationService.name); private readonly logger = new Logger(JwtVerificationService.name);
private readonly jwksClient: JwksClient; private readonly jwtSecret: string;
private readonly issuer: string; private readonly issuer: string;
private readonly audience: string | undefined; private readonly audience: string | undefined;
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
const jwksUri = this.configService.get<string>('JWKS_URI'); this.jwtSecret = this.configService.get<string>('JWT_SECRET') || '';
this.issuer = this.configService.get<string>('JWT_ISSUER') || ''; this.issuer = this.configService.get<string>('JWT_ISSUER') || 'friendolls';
this.audience = this.configService.get<string>('JWT_AUDIENCE'); this.audience = this.configService.get<string>('JWT_AUDIENCE');
if (!jwksUri) { if (!this.jwtSecret) {
throw new Error('JWKS_URI must be configured'); 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'); this.logger.log('JWT Verification Service initialized');
} }
async verifyToken(token: string): Promise<JwtPayload> { verifyToken(token: string): JwtPayload {
return new Promise((resolve, reject) => { return verify(token, this.jwtSecret, {
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, issuer: this.issuer,
audience: this.audience, audience: this.audience,
algorithms: [JWT_ALGORITHM], algorithms: [JWT_ALGORITHM],
}, }) as JwtPayload;
(err, decoded) => {
if (err) {
reject(err);
return;
}
resolve(decoded as JwtPayload);
},
);
});
} }
extractToken(handshake: { extractToken(handshake: {

View File

@@ -1,83 +1,48 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt'; import { Strategy, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { passportJwtSecret } from 'jwks-rsa';
/** /**
* JWT payload interface representing the decoded token from Keycloak * JWT payload interface representing the decoded token
*/ */
export interface JwtPayload { export interface JwtPayload {
sub: string; // Subject (user identifier in Keycloak) sub: string; // User ID
email?: string; email: string;
name?: string; roles?: string[];
preferred_username?: string; iss: string;
picture?: string; aud?: string;
realm_access?: { exp: number;
roles: string[]; iat: number;
};
resource_access?: {
[key: string]: {
roles: string[];
};
};
session_state?: string;
iss: string; // Issuer
aud: string | string[]; // Audience
exp: number; // Expiration time
iat: number; // Issued at
} }
/** /**
* JWT Strategy for validating Keycloak-issued JWT tokens. * JWT Strategy for validating locally issued JWT tokens.
* This strategy validates tokens against Keycloak's public keys (JWKS)
* and extracts user information from the token payload.
*/ */
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
private readonly logger = new Logger(JwtStrategy.name); private readonly logger = new Logger(JwtStrategy.name);
constructor(private configService: ConfigService) { constructor(private configService: ConfigService) {
const jwksUri = configService.get<string>('JWKS_URI'); const jwtSecret = configService.get<string>('JWT_SECRET');
const issuer = configService.get<string>('JWT_ISSUER'); const issuer = configService.get<string>('JWT_ISSUER') || 'friendolls';
const audience = configService.get<string>('JWT_AUDIENCE'); const audience = configService.get<string>('JWT_AUDIENCE');
if (!jwksUri) { if (!jwtSecret) {
throw new Error('JWKS_URI must be configured in environment variables'); throw new Error('JWT_SECRET must be configured in environment variables');
}
if (!issuer) {
throw new Error('JWT_ISSUER must be configured in environment variables');
} }
super({ super({
// Extract JWT from Authorization header as Bearer token // Extract JWT from Authorization header as Bearer token
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: jwtSecret,
// Use JWKS to fetch and cache Keycloak's public keys for signature verification
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri,
}),
// Verify the issuer matches our Keycloak realm
issuer, issuer,
// Verify the audience matches our client ID
audience, audience,
// Automatically reject expired tokens
ignoreExpiration: false, ignoreExpiration: false,
algorithms: ['HS256'],
// Use RS256 algorithm (Keycloak's default)
algorithms: ['RS256'],
}); });
this.logger.log(`JWT Strategy initialized`); this.logger.log(`JWT Strategy initialized`);
this.logger.log(` JWKS URI: ${jwksUri}`);
this.logger.log(` Issuer: ${issuer}`); this.logger.log(` Issuer: ${issuer}`);
this.logger.log(` Audience: ${audience || 'NOT SET'}`); this.logger.log(` Audience: ${audience || 'NOT SET'}`);
} }
@@ -91,19 +56,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
* @throws UnauthorizedException if the payload is invalid * @throws UnauthorizedException if the payload is invalid
*/ */
async validate(payload: JwtPayload): Promise<{ async validate(payload: JwtPayload): Promise<{
keycloakSub: string; userId: string;
email?: string; email: string;
name?: string;
username?: string;
picture?: string;
roles?: string[]; roles?: string[];
sessionState?: string;
}> { }> {
this.logger.debug(`Validating JWT token payload`); this.logger.debug(`Validating JWT token payload`);
this.logger.debug(` Issuer: ${payload.iss}`); this.logger.debug(` Issuer: ${payload.iss}`);
this.logger.debug( if (payload.aud) {
` Audience: ${Array.isArray(payload.aud) ? payload.aud.join(',') : payload.aud}`, this.logger.debug(` Audience: ${payload.aud}`);
); }
this.logger.debug(` Subject: ${payload.sub}`); this.logger.debug(` Subject: ${payload.sub}`);
this.logger.debug( this.logger.debug(
` Expires: ${new Date(payload.exp * 1000).toISOString()}`, ` Expires: ${new Date(payload.exp * 1000).toISOString()}`,
@@ -114,31 +75,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException('Invalid token: missing subject'); 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 = { const user = {
keycloakSub: payload.sub, userId: payload.sub,
email: payload.email, email: payload.email,
name: payload.name, roles:
username: payload.preferred_username, payload.roles && payload.roles.length > 0 ? payload.roles : undefined,
picture: payload.picture,
roles: roles.length > 0 ? roles : undefined,
sessionState: payload.session_state,
}; };
this.logger.log( 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); return Promise.resolve(user);

View File

@@ -24,17 +24,13 @@ import {
CurrentUser, CurrentUser,
type AuthenticatedUser, type AuthenticatedUser,
} from '../auth/decorators/current-user.decorator'; } from '../auth/decorators/current-user.decorator';
import { AuthService } from '../auth/auth.service';
@ApiTags('dolls') @ApiTags('dolls')
@Controller('dolls') @Controller('dolls')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
export class DollsController { export class DollsController {
constructor( constructor(private readonly dollsService: DollsService) {}
private readonly dollsService: DollsService,
private readonly authService: AuthService,
) {}
@Post() @Post()
@ApiOperation({ @ApiOperation({
@@ -51,8 +47,7 @@ export class DollsController {
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
@Body() createDollDto: CreateDollDto, @Body() createDollDto: CreateDollDto,
) { ) {
const user = await this.authService.ensureUserExists(authUser); return this.dollsService.create(authUser.userId, createDollDto);
return this.dollsService.create(user.id, createDollDto);
} }
@Get('me') @Get('me')
@@ -66,8 +61,7 @@ export class DollsController {
}) })
@ApiUnauthorizedResponse({ description: 'Unauthorized' }) @ApiUnauthorizedResponse({ description: 'Unauthorized' })
async listMyDolls(@CurrentUser() authUser: AuthenticatedUser) { async listMyDolls(@CurrentUser() authUser: AuthenticatedUser) {
const user = await this.authService.ensureUserExists(authUser); return this.dollsService.listByOwner(authUser.userId, authUser.userId);
return this.dollsService.listByOwner(user.id, user.id);
} }
@Get('user/:userId') @Get('user/:userId')
@@ -89,8 +83,7 @@ export class DollsController {
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
@Param('userId') userId: string, @Param('userId') userId: string,
) { ) {
const user = await this.authService.ensureUserExists(authUser); return this.dollsService.listByOwner(userId, authUser.userId);
return this.dollsService.listByOwner(userId, user.id);
} }
@Get(':id') @Get(':id')
@@ -109,8 +102,7 @@ export class DollsController {
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
@Param('id') id: string, @Param('id') id: string,
) { ) {
const user = await this.authService.ensureUserExists(authUser); return this.dollsService.findOne(id, authUser.userId);
return this.dollsService.findOne(id, user.id);
} }
@Patch(':id') @Patch(':id')
@@ -130,8 +122,7 @@ export class DollsController {
@Param('id') id: string, @Param('id') id: string,
@Body() updateDollDto: UpdateDollDto, @Body() updateDollDto: UpdateDollDto,
) { ) {
const user = await this.authService.ensureUserExists(authUser); return this.dollsService.update(id, authUser.userId, updateDollDto);
return this.dollsService.update(id, user.id, updateDollDto);
} }
@Delete(':id') @Delete(':id')
@@ -151,7 +142,6 @@ export class DollsController {
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
@Param('id') id: string, @Param('id') id: string,
) { ) {
const user = await this.authService.ensureUserExists(authUser); return this.dollsService.remove(id, authUser.userId);
return this.dollsService.remove(id, user.id);
} }
} }

View File

@@ -3,7 +3,6 @@ import { ThrottlerModule } from '@nestjs/throttler';
import { FriendsController } from './friends.controller'; import { FriendsController } from './friends.controller';
import { FriendsService } from './friends.service'; import { FriendsService } from './friends.service';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { AuthService } from '../auth/auth.service';
// StateGateway removed // StateGateway removed
enum FriendRequestStatus { enum FriendRequestStatus {
@@ -16,37 +15,38 @@ describe('FriendsController', () => {
let controller: FriendsController; let controller: FriendsController;
const mockAuthUser = { const mockAuthUser = {
keycloakSub: 'f:realm:user1', userId: 'user-1',
email: 'user1@example.com', email: 'user1@example.com',
name: 'User One', roles: [],
username: 'user1',
}; };
const mockUser1 = { const mockUser1 = {
id: 'user-1', id: 'user-1',
keycloakSub: 'f:realm:user1', keycloakSub: 'legacy-sub-1',
email: 'user1@example.com', email: 'user1@example.com',
name: 'User One', name: 'User One',
username: 'user1', username: 'user1',
picture: null, picture: null,
roles: [], roles: [],
passwordHash: null,
lastLoginAt: new Date(), lastLoginAt: new Date(),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; } as unknown as { passwordHash: string | null } & Record<string, unknown>;
const mockUser2 = { const mockUser2 = {
id: 'user-2', id: 'user-2',
keycloakSub: 'f:realm:user2', keycloakSub: 'legacy-sub-2',
email: 'user2@example.com', email: 'user2@example.com',
name: 'User Two', name: 'User Two',
username: 'user2', username: 'user2',
picture: null, picture: null,
roles: [], roles: [],
passwordHash: null,
lastLoginAt: new Date(), lastLoginAt: new Date(),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; } as unknown as { passwordHash: string | null } & Record<string, unknown>;
const mockFriendRequest = { const mockFriendRequest = {
id: 'request-1', id: 'request-1',
@@ -81,11 +81,6 @@ describe('FriendsController', () => {
searchUsers: jest.fn(), searchUsers: jest.fn(),
}; };
const mockAuthService = {
syncUserFromToken: jest.fn(),
ensureUserExists: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ imports: [
@@ -100,15 +95,12 @@ describe('FriendsController', () => {
providers: [ providers: [
{ provide: FriendsService, useValue: mockFriendsService }, { provide: FriendsService, useValue: mockFriendsService },
{ provide: UsersService, useValue: mockUsersService }, { provide: UsersService, useValue: mockUsersService },
{ provide: AuthService, useValue: mockAuthService },
], ],
}).compile(); }).compile();
controller = module.get<FriendsController>(FriendsController); controller = module.get<FriendsController>(FriendsController);
jest.clearAllMocks(); jest.clearAllMocks();
mockAuthService.syncUserFromToken.mockResolvedValue(mockUser1);
mockAuthService.ensureUserExists.mockResolvedValue(mockUser1);
}); });
it('should be defined', () => { it('should be defined', () => {

View File

@@ -27,7 +27,6 @@ import {
CurrentUser, CurrentUser,
type AuthenticatedUser, type AuthenticatedUser,
} from '../auth/decorators/current-user.decorator'; } from '../auth/decorators/current-user.decorator';
import { AuthService } from '../auth/auth.service';
import { SendFriendRequestDto } from './dto/send-friend-request.dto'; import { SendFriendRequestDto } from './dto/send-friend-request.dto';
import { import {
FriendRequestResponseDto, FriendRequestResponseDto,
@@ -60,7 +59,6 @@ export class FriendsController {
constructor( constructor(
private readonly friendsService: FriendsService, private readonly friendsService: FriendsService,
private readonly usersService: UsersService, private readonly usersService: UsersService,
private readonly authService: AuthService,
) {} ) {}
@Get('search') @Get('search')
@@ -87,15 +85,13 @@ export class FriendsController {
@Query() searchDto: SearchUsersDto, @Query() searchDto: SearchUsersDto,
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<UserBasicDto[]> { ): Promise<UserBasicDto[]> {
const user = await this.authService.ensureUserExists(authUser);
this.logger.debug( this.logger.debug(
`Searching users with username: ${searchDto.username || 'all'}`, `Searching users with username: ${searchDto.username || 'all'}`,
); );
const users = await this.usersService.searchUsers( const users = await this.usersService.searchUsers(
searchDto.username, searchDto.username,
user.id, authUser.userId,
); );
return users.map((u: User) => ({ return users.map((u: User) => ({
@@ -135,14 +131,12 @@ export class FriendsController {
@Body() sendRequestDto: SendFriendRequestDto, @Body() sendRequestDto: SendFriendRequestDto,
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<FriendRequestResponseDto> { ): Promise<FriendRequestResponseDto> {
const user = await this.authService.ensureUserExists(authUser);
this.logger.log( 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( const friendRequest = await this.friendsService.sendFriendRequest(
user.id, authUser.userId,
sendRequestDto.receiverId, sendRequestDto.receiverId,
); );
@@ -165,12 +159,12 @@ export class FriendsController {
async getReceivedRequests( async getReceivedRequests(
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<FriendRequestResponseDto[]> { ): Promise<FriendRequestResponseDto[]> {
const user = await this.authService.ensureUserExists(authUser); this.logger.debug(
`Getting received friend requests for user ${authUser.userId}`,
this.logger.debug(`Getting received friend requests for user ${user.id}`); );
const requests = await this.friendsService.getPendingReceivedRequests( const requests = await this.friendsService.getPendingReceivedRequests(
user.id, authUser.userId,
); );
return requests.map((req) => this.mapFriendRequestToDto(req)); return requests.map((req) => this.mapFriendRequestToDto(req));
@@ -192,11 +186,13 @@ export class FriendsController {
async getSentRequests( async getSentRequests(
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<FriendRequestResponseDto[]> { ): 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(
authUser.userId,
const requests = await this.friendsService.getPendingSentRequests(user.id); );
return requests.map((req) => this.mapFriendRequestToDto(req)); return requests.map((req) => this.mapFriendRequestToDto(req));
} }
@@ -231,13 +227,13 @@ export class FriendsController {
@Param('id') requestId: string, @Param('id') requestId: string,
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<FriendRequestResponseDto> { ): Promise<FriendRequestResponseDto> {
const user = await this.authService.ensureUserExists(authUser); this.logger.log(
`User ${authUser.userId} accepting friend request ${requestId}`,
this.logger.log(`User ${user.id} accepting friend request ${requestId}`); );
const friendRequest = await this.friendsService.acceptFriendRequest( const friendRequest = await this.friendsService.acceptFriendRequest(
requestId, requestId,
user.id, authUser.userId,
); );
return this.mapFriendRequestToDto(friendRequest); return this.mapFriendRequestToDto(friendRequest);
@@ -273,13 +269,13 @@ export class FriendsController {
@Param('id') requestId: string, @Param('id') requestId: string,
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<FriendRequestResponseDto> { ): Promise<FriendRequestResponseDto> {
const user = await this.authService.ensureUserExists(authUser); this.logger.log(
`User ${authUser.userId} denying friend request ${requestId}`,
this.logger.log(`User ${user.id} denying friend request ${requestId}`); );
const friendRequest = await this.friendsService.denyFriendRequest( const friendRequest = await this.friendsService.denyFriendRequest(
requestId, requestId,
user.id, authUser.userId,
); );
return this.mapFriendRequestToDto(friendRequest); return this.mapFriendRequestToDto(friendRequest);
@@ -301,11 +297,9 @@ export class FriendsController {
async getFriends( async getFriends(
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<FriendshipResponseDto[]> { ): 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(authUser.userId);
const friendships = await this.friendsService.getFriends(user.id);
return friendships.map((friendship) => { return friendships.map((friendship) => {
// Use Prisma generated type for safe casting // Use Prisma generated type for safe casting
@@ -366,11 +360,9 @@ export class FriendsController {
@Param('friendId') friendId: string, @Param('friendId') friendId: string,
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<void> { ): 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(authUser.userId, friendId);
await this.friendsService.unfriend(user.id, friendId);
} }
private mapFriendRequestToDto( private mapFriendRequestToDto(

View File

@@ -20,29 +20,31 @@ describe('FriendsService', () => {
const mockUser1 = { const mockUser1 = {
id: 'user-1', id: 'user-1',
keycloakSub: 'f:realm:user1', keycloakSub: 'legacy-sub-1',
email: 'user1@example.com', email: 'user1@example.com',
name: 'User One', name: 'User One',
username: 'user1', username: 'user1',
picture: null, picture: null,
roles: [], roles: [],
passwordHash: null,
lastLoginAt: new Date(), lastLoginAt: new Date(),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; } as unknown as { passwordHash: string | null } & Record<string, unknown>;
const mockUser2 = { const mockUser2 = {
id: 'user-2', id: 'user-2',
keycloakSub: 'f:realm:user2', keycloakSub: 'legacy-sub-2',
email: 'user2@example.com', email: 'user2@example.com',
name: 'User Two', name: 'User Two',
username: 'user2', username: 'user2',
picture: null, picture: null,
roles: [], roles: [],
passwordHash: null,
lastLoginAt: new Date(), lastLoginAt: new Date(),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; } as unknown as { passwordHash: string | null } & Record<string, unknown>;
const mockFriendRequest = { const mockFriendRequest = {
id: 'request-1', 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 { NotFoundException, ForbiddenException } from '@nestjs/common';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { AuthService } from '../auth/auth.service';
import { User } from './users.entity'; import { User } from './users.entity';
import type { UpdateUserDto } from './dto/update-user.dto'; import type { UpdateUserDto } from './dto/update-user.dto';
import type { AuthenticatedUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/decorators/current-user.decorator';
@@ -13,45 +12,33 @@ describe('UsersController', () => {
const mockFindOne = jest.fn(); const mockFindOne = jest.fn();
const mockUpdate = jest.fn(); const mockUpdate = jest.fn();
const mockDelete = jest.fn(); const mockDelete = jest.fn();
const mockFindByKeycloakSub = jest.fn();
const mockSyncUserFromToken = jest.fn();
const mockUsersService = { const mockUsersService = {
findOne: mockFindOne, findOne: mockFindOne,
update: mockUpdate, update: mockUpdate,
delete: mockDelete, delete: mockDelete,
findByKeycloakSub: mockFindByKeycloakSub,
};
const mockEnsureUserExists = jest.fn();
const mockAuthService = {
syncUserFromToken: mockSyncUserFromToken,
ensureUserExists: mockEnsureUserExists,
}; };
const mockAuthUser: AuthenticatedUser = { const mockAuthUser: AuthenticatedUser = {
keycloakSub: 'f:realm:user123', userId: 'uuid-123',
email: 'test@example.com', email: 'test@example.com',
name: 'Test User',
username: 'testuser',
roles: ['user'], roles: ['user'],
}; };
const mockUser: User = { const mockUser = {
id: 'uuid-123', id: 'uuid-123',
keycloakSub: 'f:realm:user123', keycloakSub: 'legacy-sub',
email: 'test@example.com', email: 'test@example.com',
name: 'Test User', name: 'Test User',
username: 'testuser', username: 'testuser',
picture: null, picture: null,
roles: ['user'], roles: ['user'],
passwordHash: null,
createdAt: new Date('2024-01-01'), createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'),
lastLoginAt: new Date('2024-01-01'), lastLoginAt: new Date('2024-01-01'),
activeDollId: null, activeDollId: null,
}; } as unknown as User & { passwordHash: string | null };
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@@ -61,10 +48,6 @@ describe('UsersController', () => {
provide: UsersService, provide: UsersService,
useValue: mockUsersService, useValue: mockUsersService,
}, },
{
provide: AuthService,
useValue: mockAuthService,
},
], ],
}).compile(); }).compile();
@@ -79,13 +62,13 @@ describe('UsersController', () => {
}); });
describe('getCurrentUser', () => { describe('getCurrentUser', () => {
it('should return the current user and sync from token', async () => { it('should return the current user', async () => {
mockSyncUserFromToken.mockResolvedValue(mockUser); mockFindOne.mockResolvedValue(mockUser);
const result = await controller.getCurrentUser(mockAuthUser); const result = await controller.getCurrentUser(mockAuthUser);
expect(result).toBe(mockUser); 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 updateDto: UpdateUserDto = { name: 'Updated Name' };
const updatedUser = { ...mockUser, name: 'Updated Name' }; const updatedUser = { ...mockUser, name: 'Updated Name' };
mockEnsureUserExists.mockResolvedValue(mockUser);
mockUpdate.mockResolvedValue(updatedUser); mockUpdate.mockResolvedValue(updatedUser);
const result = await controller.updateCurrentUser( const result = await controller.updateCurrentUser(
@@ -103,11 +85,10 @@ describe('UsersController', () => {
); );
expect(result).toBe(updatedUser); expect(result).toBe(updatedUser);
expect(mockEnsureUserExists).toHaveBeenCalledWith(mockAuthUser);
expect(mockUpdate).toHaveBeenCalledWith( expect(mockUpdate).toHaveBeenCalledWith(
mockUser.id, mockAuthUser.userId,
updateDto, updateDto,
mockAuthUser.keycloakSub, mockAuthUser.userId,
); );
}); });
}); });
@@ -160,7 +141,7 @@ describe('UsersController', () => {
expect(mockUpdate).toHaveBeenCalledWith( expect(mockUpdate).toHaveBeenCalledWith(
mockUser.id, mockUser.id,
updateDto, updateDto,
mockAuthUser.keycloakSub, mockAuthUser.userId,
); );
}); });
@@ -189,15 +170,13 @@ describe('UsersController', () => {
describe('deleteCurrentUser', () => { describe('deleteCurrentUser', () => {
it('should delete the current user account', async () => { it('should delete the current user account', async () => {
mockEnsureUserExists.mockResolvedValue(mockUser);
mockDelete.mockResolvedValue(undefined); mockDelete.mockResolvedValue(undefined);
await controller.deleteCurrentUser(mockAuthUser); await controller.deleteCurrentUser(mockAuthUser);
expect(mockEnsureUserExists).toHaveBeenCalledWith(mockAuthUser);
expect(mockDelete).toHaveBeenCalledWith( expect(mockDelete).toHaveBeenCalledWith(
mockUser.id, mockAuthUser.userId,
mockAuthUser.keycloakSub, mockAuthUser.userId,
); );
}); });
}); });
@@ -208,10 +187,7 @@ describe('UsersController', () => {
await controller.delete(mockUser.id, mockAuthUser); await controller.delete(mockUser.id, mockAuthUser);
expect(mockDelete).toHaveBeenCalledWith( expect(mockDelete).toHaveBeenCalledWith(mockUser.id, mockAuthUser.userId);
mockUser.id,
mockAuthUser.keycloakSub,
);
}); });
it('should throw ForbiddenException if deleting another user', async () => { it('should throw ForbiddenException if deleting another user', async () => {

View File

@@ -8,7 +8,6 @@ import {
HttpCode, HttpCode,
UseGuards, UseGuards,
Logger, Logger,
Post,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiTags, ApiTags,
@@ -18,7 +17,6 @@ import {
ApiBearerAuth, ApiBearerAuth,
ApiUnauthorizedResponse, ApiUnauthorizedResponse,
ApiForbiddenResponse, ApiForbiddenResponse,
ApiNoContentResponse,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { User, UserResponseDto } from './users.entity'; import { User, UserResponseDto } from './users.entity';
@@ -28,17 +26,12 @@ import {
CurrentUser, CurrentUser,
type AuthenticatedUser, type AuthenticatedUser,
} from '../auth/decorators/current-user.decorator'; } from '../auth/decorators/current-user.decorator';
import { AuthService } from '../auth/auth.service';
import { LogoutRequestDto } from './dto/logout-request.dto';
/** /**
* Users Controller * Users Controller
* *
* Handles user-related HTTP endpoints. * Handles user-related HTTP endpoints.
* All endpoints require authentication via Keycloak JWT token. * All endpoints require authentication via JWT.
*
* Note: User creation is handled automatically during authentication flow.
* Users cannot be created directly via API - they must authenticate via Keycloak.
*/ */
@ApiTags('users') @ApiTags('users')
@Controller('users') @Controller('users')
@@ -47,43 +40,15 @@ import { LogoutRequestDto } from './dto/logout-request.dto';
export class UsersController { export class UsersController {
private readonly logger = new Logger(UsersController.name); private readonly logger = new Logger(UsersController.name);
constructor( constructor(private readonly usersService: UsersService) {}
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');
}
}
/** /**
* Get current authenticated user's profile. * 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') @Get('me')
@ApiOperation({ @ApiOperation({
summary: 'Get current user profile', summary: 'Get current user profile',
description: description: 'Returns the authenticated user profile.',
'Returns the authenticated user profile. Automatically syncs data from Keycloak token.',
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -96,13 +61,9 @@ export class UsersController {
async getCurrentUser( async getCurrentUser(
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<User> { ): 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 return this.usersService.findOne(authUser.userId);
// actively sync profile data, as it's an explicit request for user info
const user = await this.authService.syncUserFromToken(authUser);
return user;
} }
/** /**
@@ -130,16 +91,12 @@ export class UsersController {
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
@Body() updateUserDto: UpdateUserDto, @Body() updateUserDto: UpdateUserDto,
): Promise<User> { ): 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( return this.usersService.update(
user.id, authUser.userId,
updateUserDto, updateUserDto,
authUser.keycloakSub, authUser.userId,
); );
} }
@@ -175,7 +132,7 @@ export class UsersController {
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<User> { ): Promise<User> {
this.logger.debug( 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); return this.usersService.findOne(id);
} }
@@ -219,20 +176,20 @@ export class UsersController {
@Body() updateUserDto: UpdateUserDto, @Body() updateUserDto: UpdateUserDto,
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<User> { ): Promise<User> {
this.logger.log(`Update user ${id} (requested by ${authUser.keycloakSub})`); this.logger.log(`Update user ${id} (requested by ${authUser.userId})`);
return this.usersService.update(id, updateUserDto, authUser.keycloakSub); return this.usersService.update(id, updateUserDto, authUser.userId);
} }
/** /**
* Delete current authenticated user's account. * Delete current authenticated user's account.
* Note: This only deletes the local user record. * 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') @Delete('me')
@ApiOperation({ @ApiOperation({
summary: 'Delete current user account', summary: 'Delete current user account',
description: 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({ @ApiResponse({
status: 204, status: 204,
@@ -245,13 +202,9 @@ export class UsersController {
async deleteCurrentUser( async deleteCurrentUser(
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<void> { ): 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 await this.usersService.delete(authUser.userId, authUser.userId);
const user = await this.authService.ensureUserExists(authUser);
// Delete the user's account
await this.usersService.delete(user.id, authUser.keycloakSub);
} }
/** /**
@@ -262,7 +215,7 @@ export class UsersController {
@ApiOperation({ @ApiOperation({
summary: 'Delete a user by ID', summary: 'Delete a user by ID',
description: 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({ @ApiParam({
name: 'id', name: 'id',
@@ -288,8 +241,8 @@ export class UsersController {
@Param('id') id: string, @Param('id') id: string,
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<void> { ): Promise<void> {
this.logger.log(`Delete user ${id} (requested by ${authUser.keycloakSub})`); this.logger.log(`Delete user ${id} (requested by ${authUser.userId})`);
await this.usersService.delete(id, authUser.keycloakSub); await this.usersService.delete(id, authUser.userId);
} }
/** /**
@@ -327,16 +280,12 @@ export class UsersController {
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<User> { ): Promise<User> {
this.logger.log( 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( return this.usersService.setActiveDoll(
user.id, authUser.userId,
dollId, dollId,
authUser.keycloakSub, authUser.userId,
); );
} }
@@ -359,13 +308,8 @@ export class UsersController {
async removeActiveDoll( async removeActiveDoll(
@CurrentUser() authUser: AuthenticatedUser, @CurrentUser() authUser: AuthenticatedUser,
): Promise<User> { ): Promise<User> {
this.logger.log( this.logger.log(`Remove active doll (requested by ${authUser.userId})`);
`Remove active doll (requested by ${authUser.keycloakSub})`,
);
// First ensure user exists in our system return this.usersService.removeActiveDoll(authUser.userId, authUser.userId);
const user = await this.authService.ensureUserExists(authUser);
return this.usersService.removeActiveDoll(user.id, authUser.keycloakSub);
} }
} }

View File

@@ -3,7 +3,7 @@ import { User as PrismaUser } from '@prisma/client';
/** /**
* User entity representing a user in the system. * 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. * This is a re-export of the Prisma User type for consistency.
* Swagger decorators are applied at the controller level. * Swagger decorators are applied at the controller level.
@@ -14,7 +14,7 @@ export type User = PrismaUser;
* User response DTO for Swagger documentation * User response DTO for Swagger documentation
* This class is only used for API documentation purposes * This class is only used for API documentation purposes
*/ */
export class UserResponseDto implements PrismaUser { export class UserResponseDto {
@ApiProperty({ @ApiProperty({
description: 'Internal unique identifier', description: 'Internal unique identifier',
example: '550e8400-e29b-41d4-a716-446655440000', example: '550e8400-e29b-41d4-a716-446655440000',
@@ -22,10 +22,10 @@ export class UserResponseDto implements PrismaUser {
id: string; id: string;
@ApiProperty({ @ApiProperty({
description: 'Keycloak subject identifier from the JWT token', description: 'Legacy Keycloak subject identifier (migration only)',
example: 'f:a1b2c3d4-e5f6-7890-abcd-ef1234567890:johndoe', example: 'f:a1b2c3d4-e5f6-7890-abcd-ef1234567890:johndoe',
}) })
keycloakSub: string; keycloakSub: string | null;
@ApiProperty({ @ApiProperty({
description: "User's display name", description: "User's display name",
@@ -40,7 +40,7 @@ export class UserResponseDto implements PrismaUser {
email: string; email: string;
@ApiProperty({ @ApiProperty({
description: "User's preferred username from Keycloak", description: "User's preferred username",
example: 'johndoe', example: 'johndoe',
required: false, required: false,
nullable: true, nullable: true,
@@ -56,7 +56,7 @@ export class UserResponseDto implements PrismaUser {
picture: string | null; picture: string | null;
@ApiProperty({ @ApiProperty({
description: "User's roles from Keycloak", description: "User's roles",
example: ['user', 'premium'], example: ['user', 'premium'],
type: [String], type: [String],
isArray: true, isArray: true,

View File

@@ -9,7 +9,7 @@ import { WsModule } from '../ws/ws.module';
* Users Module * Users Module
* *
* Manages user-related functionality including user profile management * 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) * The module exports UsersService to allow other modules (like AuthModule)
* to access user data and perform synchronization. * 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 { NotFoundException, ForbiddenException } from '@nestjs/common';
import { User } from '@prisma/client'; import { User } from '@prisma/client';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/client';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
describe('UsersService', () => { describe('UsersService', () => {
let service: UsersService; let service: UsersService;
const mockUser: User = { const mockUser: User & { passwordHash?: string | null } = {
id: '550e8400-e29b-41d4-a716-446655440000', id: '550e8400-e29b-41d4-a716-446655440000',
keycloakSub: 'f:realm:user123', keycloakSub: 'f:realm:user123',
email: 'test@example.com', email: 'test@example.com',
@@ -18,6 +17,7 @@ describe('UsersService', () => {
username: 'testuser', username: 'testuser',
picture: 'https://example.com/avatar.jpg', picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'], roles: ['user', 'premium'],
passwordHash: null,
lastLoginAt: new Date('2024-01-15T10:30:00.000Z'), lastLoginAt: new Date('2024-01-15T10:30:00.000Z'),
createdAt: new Date('2024-01-01T00:00:00.000Z'), createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T10:30:00.000Z'), updatedAt: new Date('2024-01-15T10:30:00.000Z'),
@@ -63,291 +63,29 @@ describe('UsersService', () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
describe('createFromToken', () => { describe('createLocalUser', () => {
it('should create a new user when user does not exist', async () => { it('should create a local user with password hash', async () => {
const tokenData = { const dto = {
keycloakSub: 'f:realm:newuser',
email: 'john@example.com', email: 'john@example.com',
name: 'John Doe', name: 'John Doe',
username: 'johndoe', username: 'johndoe',
picture: 'https://example.com/avatar.jpg', passwordHash: 'hashed',
roles: ['user', 'premium'],
}; };
mockPrismaService.user.findUnique.mockResolvedValue(null); mockPrismaService.user.create.mockResolvedValue(mockUser);
mockPrismaService.user.upsert.mockResolvedValue({
...mockUser,
...tokenData,
});
const user = await service.createFromToken(tokenData); const user = await service.createLocalUser(dto);
expect(user).toBeDefined(); expect(user).toBeDefined();
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ expect(mockPrismaService.user.create).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 },
data: expect.objectContaining({ 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', () => { describe('findOne', () => {
@@ -382,11 +120,7 @@ describe('UsersService', () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser); mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue(mockUser); mockPrismaService.user.update.mockResolvedValue(mockUser);
const user = await service.update( const user = await service.update(mockUser.id, updateDto, mockUser.id);
mockUser.id,
updateDto,
mockUser.keycloakSub,
);
expect(user).toEqual(mockUser); expect(user).toEqual(mockUser);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({ expect(mockPrismaService.user.update).toHaveBeenCalledWith({
@@ -401,7 +135,7 @@ describe('UsersService', () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser); mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
await expect( await expect(
service.update(mockUser.id, updateDto, 'different-keycloak-sub'), service.update(mockUser.id, updateDto, 'different-user-id'),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
expect(mockPrismaService.user.update).not.toHaveBeenCalled(); expect(mockPrismaService.user.update).not.toHaveBeenCalled();
@@ -413,7 +147,7 @@ describe('UsersService', () => {
mockPrismaService.user.findUnique.mockResolvedValue(null); mockPrismaService.user.findUnique.mockResolvedValue(null);
await expect( await expect(
service.update('nonexistent', updateDto, 'any-keycloak-sub'), service.update('nonexistent', updateDto, 'any-user-id'),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(NotFoundException);
}); });
}); });
@@ -423,7 +157,7 @@ describe('UsersService', () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser); mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.delete.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({ expect(mockPrismaService.user.delete).toHaveBeenCalledWith({
where: { id: mockUser.id }, where: { id: mockUser.id },
@@ -434,7 +168,7 @@ describe('UsersService', () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser); mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
await expect( await expect(
service.delete(mockUser.id, 'different-keycloak-sub'), service.delete(mockUser.id, 'different-user-id'),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
expect(mockPrismaService.user.delete).not.toHaveBeenCalled(); expect(mockPrismaService.user.delete).not.toHaveBeenCalled();
@@ -444,165 +178,11 @@ describe('UsersService', () => {
mockPrismaService.user.findUnique.mockResolvedValue(null); mockPrismaService.user.findUnique.mockResolvedValue(null);
await expect( await expect(
service.delete('nonexistent', 'any-keycloak-sub'), service.delete('nonexistent', 'any-user-id'),
).rejects.toThrow(NotFoundException); ).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', () => { describe('searchUsers', () => {
const users: User[] = [ const users: User[] = [
{ ...mockUser, id: 'user1', username: 'alice' }, { ...mockUser, id: 'user1', username: 'alice' },
@@ -760,7 +340,7 @@ describe('UsersService', () => {
const result = await service.setActiveDoll( const result = await service.setActiveDoll(
mockUser.id, mockUser.id,
dollId, dollId,
mockUser.keycloakSub, mockUser.id,
); );
expect(result).toEqual(updatedUser); expect(result).toEqual(updatedUser);
@@ -775,7 +355,7 @@ describe('UsersService', () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser); mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
await expect( await expect(
service.setActiveDoll(mockUser.id, dollId, 'other-keycloak-sub'), service.setActiveDoll(mockUser.id, dollId, 'other-user-id'),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
@@ -788,7 +368,7 @@ describe('UsersService', () => {
mockPrismaService.doll.findUnique.mockResolvedValue(null); mockPrismaService.doll.findUnique.mockResolvedValue(null);
await expect( await expect(
service.setActiveDoll(mockUser.id, dollId, mockUser.keycloakSub), service.setActiveDoll(mockUser.id, dollId, mockUser.id),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(NotFoundException);
}); });
@@ -802,7 +382,7 @@ describe('UsersService', () => {
mockPrismaService.doll.findUnique.mockResolvedValue(deletedDoll); mockPrismaService.doll.findUnique.mockResolvedValue(deletedDoll);
await expect( await expect(
service.setActiveDoll(mockUser.id, dollId, mockUser.keycloakSub), service.setActiveDoll(mockUser.id, dollId, mockUser.id),
).rejects.toThrow(NotFoundException); ).rejects.toThrow(NotFoundException);
}); });
@@ -816,7 +396,7 @@ describe('UsersService', () => {
mockPrismaService.doll.findUnique.mockResolvedValue(otherUserDoll); mockPrismaService.doll.findUnique.mockResolvedValue(otherUserDoll);
await expect( await expect(
service.setActiveDoll(mockUser.id, dollId, mockUser.keycloakSub), service.setActiveDoll(mockUser.id, dollId, mockUser.id),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
}); });
@@ -828,10 +408,7 @@ describe('UsersService', () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser); mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue(updatedUser); mockPrismaService.user.update.mockResolvedValue(updatedUser);
const result = await service.removeActiveDoll( const result = await service.removeActiveDoll(mockUser.id, mockUser.id);
mockUser.id,
mockUser.keycloakSub,
);
expect(result).toEqual(updatedUser); expect(result).toEqual(updatedUser);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({ expect(mockPrismaService.user.update).toHaveBeenCalledWith({
@@ -844,7 +421,7 @@ describe('UsersService', () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser); mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
await expect( await expect(
service.removeActiveDoll(mockUser.id, 'other-keycloak-sub'), service.removeActiveDoll(mockUser.id, 'other-user-id'),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
}); });
}); });

View File

@@ -8,51 +8,19 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaService } from '../database/prisma.service'; import { PrismaService } from '../database/prisma.service';
import { User, Prisma } from '@prisma/client'; import { User, Prisma } from '@prisma/client';
import type { UpdateUserDto } from './dto/update-user.dto'; import type { UpdateUserDto } from './dto/update-user.dto';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/client';
import { UserEvents } from './events/user.events'; import { UserEvents } from './events/user.events';
/** export interface CreateLocalUserDto {
* Interface for creating a user from Keycloak token
*/
export interface CreateUserFromTokenDto {
keycloakSub: string;
email: string; email: string;
name: string; name: string;
username?: string; username?: string;
picture?: string; passwordHash: string;
roles?: string[];
} }
/** /**
* Users Service * Users Service
* *
* Manages user data synchronized from Keycloak OIDC using Prisma ORM. * Manages user data for local authentication 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.
*/ */
@Injectable() @Injectable()
export class UsersService { export class UsersService {
@@ -63,215 +31,11 @@ export class UsersService {
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
) {} ) {}
/** // Legacy Keycloak user creation removed in favor of local auth.
* 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 },
});
// 2. If found, return immediately without update // Legacy Keycloak sync logic removed in favor of local auth.
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 docs removed in favor of local auth.
/** /**
* Finds a user by their internal ID. * Finds a user by their internal ID.
* *
@@ -293,12 +57,12 @@ export class UsersService {
/** /**
* Updates a user's profile. * Updates a user's profile.
* Currently, all profile fields are managed by Keycloak and cannot be updated locally. * Currently, no profile fields are updatable locally.
* This method exists for future extensibility if local profile fields are added. * This method exists for future extensibility if local fields are added.
* *
* @param id - The user's internal ID * @param id - The user's internal ID
* @param updateUserDto - The fields to update (currently none supported) * @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 * @returns The updated user
* @throws NotFoundException if the user is not found * @throws NotFoundException if the user is not found
* @throws ForbiddenException if the user tries to update someone else's profile * @throws ForbiddenException if the user tries to update someone else's profile
@@ -306,14 +70,14 @@ export class UsersService {
async update( async update(
id: string, id: string,
updateUserDto: UpdateUserDto, updateUserDto: UpdateUserDto,
requestingUserKeycloakSub: string, requestingUserId: string,
): Promise<User> { ): Promise<User> {
const user = await this.findOne(id); const user = await this.findOne(id);
// Verify the user is updating their own profile // Verify the user is updating their own profile
if (user.keycloakSub !== requestingUserKeycloakSub) { if (user.id !== requestingUserId) {
this.logger.warn( 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'); 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 NotFoundException if the user is not found
* @throws ForbiddenException if the user tries to delete someone else's account * @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); const user = await this.findOne(id);
if (user.keycloakSub !== requestingUserKeycloakSub) { if (user.id !== requestingUserId) {
this.logger.warn( 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'); throw new ForbiddenException('You can only delete your own account');
} }
@@ -356,9 +120,7 @@ export class UsersService {
where: { id }, where: { id },
}); });
this.logger.log( this.logger.log(`User ${id} deleted their account`);
`User ${id} deleted their account (Keycloak: ${requestingUserKeycloakSub})`,
);
} }
async searchUsers( async searchUsers(
@@ -403,12 +165,12 @@ export class UsersService {
async setActiveDoll( async setActiveDoll(
userId: string, userId: string,
dollId: string, dollId: string,
requestingUserKeycloakSub: string, requestingUserId: string,
): Promise<User> { ): Promise<User> {
const user = await this.findOne(userId); const user = await this.findOne(userId);
// Verify the user is updating their own profile // 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'); throw new ForbiddenException('You can only update your own profile');
} }
@@ -452,12 +214,12 @@ export class UsersService {
*/ */
async removeActiveDoll( async removeActiveDoll(
userId: string, userId: string,
requestingUserKeycloakSub: string, requestingUserId: string,
): Promise<User> { ): Promise<User> {
const user = await this.findOne(userId); const user = await this.findOne(userId);
// Verify the user is updating their own profile // 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'); throw new ForbiddenException('You can only update your own profile');
} }
@@ -477,4 +239,42 @@ export class UsersService {
return updatedUser; 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 { Test, TestingModule } from '@nestjs/testing';
import { StateGateway } from './state.gateway'; import { StateGateway } from './state.gateway';
import { AuthenticatedSocket } from '../../types/socket'; import { AuthenticatedSocket } from '../../types/socket';
import { AuthService } from '../../auth/auth.service';
import { JwtVerificationService } from '../../auth/services/jwt-verification.service'; import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
import { UsersService } from '../../users/users.service';
import { PrismaService } from '../../database/prisma.service'; import { PrismaService } from '../../database/prisma.service';
import { UserSocketService } from './user-socket.service'; import { UserSocketService } from './user-socket.service';
import { WsNotificationService } from './ws-notification.service'; import { WsNotificationService } from './ws-notification.service';
@@ -12,15 +12,13 @@ import { WsException } from '@nestjs/websockets';
import { UserStatusDto, UserState } from '../dto/user-status.dto'; import { UserStatusDto, UserState } from '../dto/user-status.dto';
interface MockSocket extends Partial<AuthenticatedSocket> { type MockSocket = {
id: string; id: string;
data: { data: {
user?: { user?: {
keycloakSub: string; userId: string;
email?: string; email: string;
name?: string; roles?: string[];
preferred_username?: string;
picture?: string;
}; };
userId?: string; userId?: string;
activeDollId?: string | null; activeDollId?: string | null;
@@ -29,7 +27,7 @@ interface MockSocket extends Partial<AuthenticatedSocket> {
handshake?: any; handshake?: any;
disconnect?: jest.Mock; disconnect?: jest.Mock;
emit?: jest.Mock; emit?: jest.Mock;
} };
describe('StateGateway', () => { describe('StateGateway', () => {
let gateway: StateGateway; let gateway: StateGateway;
@@ -41,7 +39,7 @@ describe('StateGateway', () => {
sockets: { sockets: { size: number; get: jest.Mock } }; sockets: { sockets: { size: number; get: jest.Mock } };
to: jest.Mock; to: jest.Mock;
}; };
let mockAuthService: Partial<AuthService>; let mockUsersService: Partial<UsersService>;
let mockJwtVerificationService: Partial<JwtVerificationService>; let mockJwtVerificationService: Partial<JwtVerificationService>;
let mockPrismaService: Partial<PrismaService>; let mockPrismaService: Partial<PrismaService>;
let mockUserSocketService: Partial<UserSocketService>; let mockUserSocketService: Partial<UserSocketService>;
@@ -69,16 +67,15 @@ describe('StateGateway', () => {
}), }),
}; };
mockAuthService = { mockUsersService = {
syncUserFromToken: jest.fn().mockResolvedValue({ findOne: jest.fn().mockResolvedValue({
id: 'user-id', id: 'user-id',
keycloakSub: 'test-sub',
}), }),
}; };
mockJwtVerificationService = { mockJwtVerificationService = {
extractToken: jest.fn((handshake) => handshake.auth?.token), extractToken: jest.fn((handshake) => handshake.auth?.token),
verifyToken: jest.fn().mockResolvedValue({ verifyToken: jest.fn().mockReturnValue({
sub: 'test-sub', sub: 'test-sub',
email: 'test@example.com', email: 'test@example.com',
}), }),
@@ -122,7 +119,7 @@ describe('StateGateway', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
StateGateway, StateGateway,
{ provide: AuthService, useValue: mockAuthService }, { provide: UsersService, useValue: mockUsersService },
{ {
provide: JwtVerificationService, provide: JwtVerificationService,
useValue: mockJwtVerificationService, useValue: mockJwtVerificationService,
@@ -172,7 +169,7 @@ describe('StateGateway', () => {
}); });
describe('handleConnection', () => { 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 = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: {}, data: {},
@@ -183,9 +180,7 @@ describe('StateGateway', () => {
disconnect: jest.fn(), disconnect: jest.fn(),
}; };
await gateway.handleConnection( gateway.handleConnection(mockClient as unknown as AuthenticatedSocket);
mockClient as unknown as AuthenticatedSocket,
);
expect(mockJwtVerificationService.extractToken).toHaveBeenCalledWith( expect(mockJwtVerificationService.extractToken).toHaveBeenCalledWith(
mockClient.handshake, mockClient.handshake,
@@ -195,13 +190,13 @@ describe('StateGateway', () => {
); );
// Should NOT call these anymore in handleConnection // Should NOT call these anymore in handleConnection
expect(mockAuthService.syncUserFromToken).not.toHaveBeenCalled(); expect(mockUsersService.findOne).not.toHaveBeenCalled();
expect(mockUserSocketService.setSocket).not.toHaveBeenCalled(); expect(mockUserSocketService.setSocket).not.toHaveBeenCalled();
// Should set data on client // Should set data on client
expect(mockClient.data.user).toEqual( expect(mockClient.data.user).toEqual(
expect.objectContaining({ expect.objectContaining({
keycloakSub: 'test-sub', userId: 'test-sub',
}), }),
); );
expect(mockClient.data.activeDollId).toBeNull(); 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 = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: {}, data: {},
@@ -226,9 +221,7 @@ describe('StateGateway', () => {
undefined, undefined,
); );
await gateway.handleConnection( gateway.handleConnection(mockClient as unknown as AuthenticatedSocket);
mockClient as unknown as AuthenticatedSocket,
);
expect(mockLoggerWarn).toHaveBeenCalledWith( expect(mockLoggerWarn).toHaveBeenCalledWith(
'WebSocket connection attempt without token', 'WebSocket connection attempt without token',
@@ -242,7 +235,7 @@ describe('StateGateway', () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
user: { keycloakSub: 'test-sub' }, user: { userId: 'test-sub', email: 'test@example.com' },
friends: new Set(), friends: new Set(),
}, },
emit: jest.fn(), emit: jest.fn(),
@@ -262,10 +255,8 @@ describe('StateGateway', () => {
mockClient as unknown as AuthenticatedSocket, mockClient as unknown as AuthenticatedSocket,
); );
// 1. Sync User // 1. Load User
expect(mockAuthService.syncUserFromToken).toHaveBeenCalledWith( expect(mockUsersService.findOne).toHaveBeenCalledWith('test-sub');
mockClient.data.user,
);
// 2. Set Socket // 2. Set Socket
expect(mockUserSocketService.setSocket).toHaveBeenCalledWith( expect(mockUserSocketService.setSocket).toHaveBeenCalledWith(
@@ -320,7 +311,7 @@ describe('StateGateway', () => {
it('should log client disconnection', async () => { it('should log client disconnection', async () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { user: { keycloakSub: 'test-sub' } }, data: { user: { userId: 'test-sub', email: 'test@example.com' } },
}; };
await gateway.handleDisconnect( await gateway.handleDisconnect(
@@ -351,7 +342,7 @@ describe('StateGateway', () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
user: { keycloakSub: 'test-sub' }, user: { userId: 'test-sub', email: 'test@example.com' },
userId: 'user-id', userId: 'user-id',
friends: new Set(['friend-1']), friends: new Set(['friend-1']),
}, },
@@ -385,7 +376,7 @@ describe('StateGateway', () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
user: { keycloakSub: 'test-sub' }, user: { userId: 'test-sub', email: 'test@example.com' },
userId: 'user-1', userId: 'user-1',
activeDollId: 'doll-1', // User must have active doll activeDollId: 'doll-1', // User must have active doll
friends: new Set(['friend-1']), friends: new Set(['friend-1']),
@@ -422,7 +413,7 @@ describe('StateGateway', () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
user: { keycloakSub: 'test-sub' }, user: { userId: 'test-sub', email: 'test@example.com' },
userId: 'user-1', userId: 'user-1',
activeDollId: null, // No doll activeDollId: null, // No doll
friends: new Set(['friend-1']), friends: new Set(['friend-1']),
@@ -443,7 +434,7 @@ describe('StateGateway', () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
user: { keycloakSub: 'test-sub' }, user: { userId: 'test-sub', email: 'test@example.com' },
// userId is missing // userId is missing
friends: new Set(['friend-1']), friends: new Set(['friend-1']),
}, },
@@ -482,7 +473,7 @@ describe('StateGateway', () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
user: { keycloakSub: 'test-sub' }, user: { userId: 'test-sub', email: 'test@example.com' },
userId: 'user-1', userId: 'user-1',
activeDollId: 'doll-1', // User must have active doll activeDollId: 'doll-1', // User must have active doll
friends: new Set(['friend-1']), friends: new Set(['friend-1']),
@@ -523,7 +514,7 @@ describe('StateGateway', () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
user: { keycloakSub: 'test-sub' }, user: { userId: 'test-sub', email: 'test@example.com' },
userId: 'user-1', userId: 'user-1',
activeDollId: null, // No doll activeDollId: null, // No doll
friends: new Set(['friend-1']), friends: new Set(['friend-1']),
@@ -551,7 +542,7 @@ describe('StateGateway', () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
user: { keycloakSub: 'test-sub' }, user: { userId: 'test-sub', email: 'test@example.com' },
// userId is missing // userId is missing
friends: new Set(['friend-1']), friends: new Set(['friend-1']),
}, },
@@ -601,7 +592,7 @@ describe('StateGateway', () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
user: { keycloakSub: 'test-sub' }, user: { userId: 'test-sub', email: 'test@example.com' },
userId: 'user-1', userId: 'user-1',
activeDollId: 'doll-1', activeDollId: 'doll-1',
friends: new Set(['friend-1']), friends: new Set(['friend-1']),
@@ -652,7 +643,7 @@ describe('StateGateway', () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
user: { keycloakSub: 'test-sub', name: 'TestUser' }, user: { userId: 'test-sub', email: 'test@example.com' },
userId: 'user-1', userId: 'user-1',
friends: new Set(['friend-1']), friends: new Set(['friend-1']),
}, },
@@ -677,7 +668,6 @@ describe('StateGateway', () => {
'interaction-received', 'interaction-received',
expect.objectContaining({ expect.objectContaining({
senderUserId: 'user-1', senderUserId: 'user-1',
senderName: 'TestUser',
content: 'hello', content: 'hello',
type: 'text', type: 'text',
}), }),
@@ -688,7 +678,7 @@ describe('StateGateway', () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
user: { keycloakSub: 'test-sub' }, user: { userId: 'test-sub', email: 'test@example.com' },
userId: 'user-1', userId: 'user-1',
friends: new Set(['friend-1']), friends: new Set(['friend-1']),
}, },
@@ -719,7 +709,7 @@ describe('StateGateway', () => {
const mockClient: MockSocket = { const mockClient: MockSocket = {
id: 'client1', id: 'client1',
data: { data: {
user: { keycloakSub: 'test-sub' }, user: { userId: 'test-sub', email: 'test@example.com' },
userId: 'user-1', userId: 'user-1',
friends: new Set(['friend-1']), friends: new Set(['friend-1']),
}, },

View File

@@ -15,7 +15,6 @@ import {
REDIS_SUBSCRIBER_CLIENT, REDIS_SUBSCRIBER_CLIENT,
} from '../../database/redis.module'; } from '../../database/redis.module';
import type { AuthenticatedSocket } from '../../types/socket'; import type { AuthenticatedSocket } from '../../types/socket';
import { AuthService } from '../../auth/auth.service';
import { JwtVerificationService } from '../../auth/services/jwt-verification.service'; import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
import { CursorPositionDto } from '../dto/cursor-position.dto'; import { CursorPositionDto } from '../dto/cursor-position.dto';
import { UserStatusDto } from '../dto/user-status.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 { UserSocketService } from './user-socket.service';
import { WsNotificationService } from './ws-notification.service'; import { WsNotificationService } from './ws-notification.service';
import { WS_EVENT, REDIS_CHANNEL } from './ws-events'; import { WS_EVENT, REDIS_CHANNEL } from './ws-events';
import { UsersService } from '../../users/users.service';
const USER_STATUS_BROADCAST_THROTTLING_MS = 200; const USER_STATUS_BROADCAST_THROTTLING_MS = 200;
@@ -43,9 +43,9 @@ export class StateGateway
@WebSocketServer() io: Server; @WebSocketServer() io: Server;
constructor( constructor(
private readonly authService: AuthService,
private readonly jwtVerificationService: JwtVerificationService, private readonly jwtVerificationService: JwtVerificationService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly usersService: UsersService,
private readonly userSocketService: UserSocketService, private readonly userSocketService: UserSocketService,
private readonly wsNotificationService: WsNotificationService, private readonly wsNotificationService: WsNotificationService,
@Inject(REDIS_CLIENT) private readonly redisClient: Redis | null, @Inject(REDIS_CLIENT) private readonly redisClient: Redis | null,
@@ -118,7 +118,7 @@ export class StateGateway
} }
} }
async handleConnection(client: AuthenticatedSocket) { handleConnection(client: AuthenticatedSocket) {
try { try {
this.logger.debug( this.logger.debug(
`Connection attempt - handshake auth: ${JSON.stringify(client.handshake.auth)}`, `Connection attempt - handshake auth: ${JSON.stringify(client.handshake.auth)}`,
@@ -135,18 +135,16 @@ export class StateGateway
return; return;
} }
const payload = await this.jwtVerificationService.verifyToken(token); const payload = this.jwtVerificationService.verifyToken(token);
if (!payload.sub) { if (!payload.sub) {
throw new WsException('Invalid token: missing subject'); throw new WsException('Invalid token: missing subject');
} }
client.data.user = { client.data.user = {
keycloakSub: payload.sub, userId: payload.sub,
email: payload.email, email: payload.email,
name: payload.name, roles: payload.roles,
username: payload.preferred_username,
picture: payload.picture,
}; };
// Initialize defaults // Initialize defaults
@@ -186,17 +184,15 @@ export class StateGateway
throw new WsException('Unauthorized: No user data found'); throw new WsException('Unauthorized: No user data found');
} }
const payload = await this.jwtVerificationService.verifyToken(token); const payload = this.jwtVerificationService.verifyToken(token);
if (!payload.sub) { if (!payload.sub) {
throw new WsException('Invalid token: missing subject'); throw new WsException('Invalid token: missing subject');
} }
userTokenData = { userTokenData = {
keycloakSub: payload.sub, userId: payload.sub,
email: payload.email, email: payload.email,
name: payload.name, roles: payload.roles,
username: payload.preferred_username,
picture: payload.picture,
}; };
client.data.user = userTokenData; client.data.user = userTokenData;
@@ -209,8 +205,7 @@ export class StateGateway
); );
} }
// 1. Sync user from token (DB Write/Read) const user = await this.usersService.findOne(userTokenData.userId);
const user = await this.authService.syncUserFromToken(userTokenData);
// 2. Register socket mapping (Redis Write) // 2. Register socket mapping (Redis Write)
await this.userSocketService.setSocket(user.id, client.id); await this.userSocketService.setSocket(user.id, client.id);
@@ -283,7 +278,7 @@ export class StateGateway
} }
this.logger.log( 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 // 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 = { const payload: InteractionPayloadDto = {
senderUserId: currentUserId, senderUserId: currentUserId,
senderName: user.name || user.username || 'Unknown', senderName,
content: data.content, content: data.content,
type: data.type, type: data.type,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),

View File

@@ -3,11 +3,17 @@ import { StateGateway } from './state/state.gateway';
import { WsNotificationService } from './state/ws-notification.service'; import { WsNotificationService } from './state/ws-notification.service';
import { UserSocketService } from './state/user-socket.service'; import { UserSocketService } from './state/user-socket.service';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module';
import { FriendsModule } from '../friends/friends.module'; import { FriendsModule } from '../friends/friends.module';
import { RedisModule } from '../database/redis.module'; import { RedisModule } from '../database/redis.module';
@Module({ @Module({
imports: [AuthModule, RedisModule, forwardRef(() => FriendsModule)], imports: [
AuthModule,
forwardRef(() => UsersModule),
RedisModule,
forwardRef(() => FriendsModule),
],
providers: [StateGateway, WsNotificationService, UserSocketService], providers: [StateGateway, WsNotificationService, UserSocketService],
exports: [StateGateway, WsNotificationService, UserSocketService], exports: [StateGateway, WsNotificationService, UserSocketService],
}) })