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