feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature

This commit is contained in:
diallolatoile 2025-10-27 18:13:37 +00:00
parent 0418a15343
commit d43f5921e5
24 changed files with 1575 additions and 1242 deletions

View File

@ -1,16 +1,28 @@
# .env
# .env-sample
NODE_ENV=development
PORT=3000
KEYCLOAK_SERVER_URL=https://keycloak-dcb.app.cameleonapp.com
KEYCLOAK_REALM=dcb-dev
KEYCLOAK_JWKS_URI=https://keycloak-dcb.app.cameleonapp.com/realms/dcb-dev/protocol/openid-connect/certs
KEYCLOAK_ISSUER=https://keycloak-dcb.app.cameleonapp.com/realms/dcb-dev
KEYCLOAK_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwfT6BGerQyJ7EOFcgN1DLxRh/8g3cCN5qNZyeLQc6524Lsw3voMD2HJddvAunCcn6Eux2LTYXPzLvZc8829Sa5ksTzINyPqg9GFZa5+GAifMW6DfvQcxGyl5yvduCWxOSmST3PYN9UkCFP20e3gDLRox9rNe1/17xkDJwByJh/Xld/m07vHgyglDNRGkA/YW3A1JuAKgJjAstLOyeK+UGdMeJmD/5TF/yoBI/FsjW/OjZ78wP3dfkGo5zG2EOkK+39evU7HxB4jgL5SBhw32GLPVhtyCMnUW6IlsQhDSDWXqBdMCO0/hdrjyznyM7ZJqkUN7KAFKqcJsnja9mBNT4QIDAQAB
KEYCLOAK_ADMIN_CLIENT_ID=dcb-user-service-cc
KEYCLOAK_ADMIN_CLIENT_SECRET=VS7fDASmxmPOjn0JkhbtNDh7ULm0QGGa
KEYCLOAK_AUTH_CLIENT_ID=dcb-user-service-pwd
KEYCLOAK_AUTH_CLIENT_SECRET=J0VvIiiJST40SD3apiQ206r1xNCERFD2
KEYCLOAK_CLIENT_ID=dcb-user-service-pwd
KEYCLOAK_CLIENT_SECRET=J0VvIiiJST40SD3apiQ206r1xNCERFD2
KEYCLOAK_VALIDATION_MODE=offline
KEYCLOAK_TOKEN_BUFFER_SECONDS=30
KEYCLOAK_TEST_USER_ADMIN=dev-bo-admin
KEYCLOAK_TEST_PASSWORD_ADMIN=@BOAdmin2025
KEYCLOAK_TEST_USER_MERCHANT=dev-bo-merchant
KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOMerchant2025
KEYCLOAK_TEST_USER_SUPPORT=dev-bo-support
KEYCLOAK_TEST_PASSWORD=@BOSupport2025

186
package-lock.json generated
View File

@ -10,22 +10,28 @@
"license": "UNLICENSED",
"dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.1.7",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.7",
"@nestjs/jwt": "^11.0.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.7",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"axios": "^1.12.2",
"cache-manager": "^7.2.4",
"circuit-breaker-ts": "^0.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"helmet": "^8.1.0",
"jwks-rsa": "^3.2.0",
"jwt-decode": "^4.0.0",
"keycloak-connect": "^26.1.1",
"nest-keycloak-connect": "^1.10.1",
"passport": "^0.7.0",
"passport-http-bearer": "^1.0.1",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
@ -727,6 +733,24 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@cacheable/utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.1.0.tgz",
"integrity": "sha512-ZdxfOiaarMqMj+H7qwlt5EBKWaeGihSYVHdQv5lUsbn8MJJOTW82OIwirQ39U5tMZkNvy3bQE+ryzC+xTAb9/g==",
"license": "MIT",
"dependencies": {
"keyv": "^5.5.3"
}
},
"node_modules/@cacheable/utils/node_modules/keyv": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz",
"integrity": "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==",
"license": "MIT",
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@ -2123,6 +2147,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@keyv/serialize": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
"integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
"license": "MIT"
},
"node_modules/@lukeed/csprng": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
@ -2156,6 +2186,19 @@
"rxjs": "^7.0.0"
}
},
"node_modules/@nestjs/cache-manager": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.0.1.tgz",
"integrity": "sha512-4UxTnR0fsmKL5YDalU2eLFVnL+OBebWUpX+hEduKGncrVKH4PPNoiRn1kXyOCjmzb0UvWgqubpssNouc8e0MCw==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0",
"cache-manager": ">=6",
"keyv": ">=5",
"rxjs": "^7.8.1"
}
},
"node_modules/@nestjs/cli": {
"version": "11.0.10",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.10.tgz",
@ -2487,6 +2530,16 @@
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/passport": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
"integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"passport": "^0.5.0 || ^0.6.0 || ^0.7.0"
}
},
"node_modules/@nestjs/platform-express": {
"version": "11.1.7",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.7.tgz",
@ -2986,7 +3039,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@ -2997,7 +3049,6 @@
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@ -3095,7 +3146,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
@ -3209,7 +3259,6 @@
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
@ -3253,21 +3302,18 @@
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz",
"integrity": "sha512-zBF6vZJn1IaMpg3xUF25VK3gd3l8zwE0ZLRX7dsQyQi+jp4E8mMDJNGDYnYse+bQhYwWERTxVwHpi3dMOq7RKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@ -3277,7 +3323,6 @@
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.9.tgz",
"integrity": "sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@ -3289,7 +3334,6 @@
"version": "0.17.5",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
"integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mime": "^1",
@ -4810,6 +4854,25 @@
"node": ">= 0.8"
}
},
"node_modules/cache-manager": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.4.tgz",
"integrity": "sha512-skmhkqXjPCBmrb70ctEx4zwFk7vb0RdFXlVGYWnFZ8pKvkzdFrFFKSJ1IaKduGfkryHOJvb7q2PkGmonmL+UGw==",
"license": "MIT",
"dependencies": {
"@cacheable/utils": "^2.1.0",
"keyv": "^5.5.3"
}
},
"node_modules/cache-manager/node_modules/keyv": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.3.tgz",
"integrity": "sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==",
"license": "MIT",
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@ -4989,6 +5052,12 @@
"node": ">=8"
}
},
"node_modules/circuit-breaker-ts": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/circuit-breaker-ts/-/circuit-breaker-ts-0.1.0.tgz",
"integrity": "sha512-egw3mLBRGtncvnwqLBcCN2KI8H9B7mcSMgk2vibxh7Z9egtCcLRsMXVDOGl2ZUE/z2aHdu+8ldmWiZeg+b2XHQ==",
"license": "MIT"
},
"node_modules/cjs-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz",
@ -8082,6 +8151,15 @@
"node": ">= 20"
}
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -8220,6 +8298,47 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/jwks-rsa": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz",
"integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==",
"license": "MIT",
"dependencies": {
"@types/express": "^4.17.20",
"@types/jsonwebtoken": "^9.0.4",
"debug": "^4.3.4",
"jose": "^4.15.4",
"limiter": "^1.1.5",
"lru-memoizer": "^2.2.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/jwks-rsa/node_modules/@types/express": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.24.tgz",
"integrity": "sha512-Mbrt4SRlXSTWryOnHAh2d4UQ/E7n9lZyGSi6KgX+4hkuL9soYbLOVXVhnk/ODp12YsGc95f4pOvqywJ6kngUwg==",
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^4.17.33",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"node_modules/jwks-rsa/node_modules/@types/express-serve-static-core": {
"version": "4.19.7",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz",
"integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
@ -8294,6 +8413,11 @@
"integrity": "sha512-l5IlyL9AONj4voSd7q9xkuQOL4u8Ty44puTic7J88CmdXkxfGsRfoVLXHCxppwehgpb/Chdb80FFehHqjN3ItQ==",
"license": "MIT"
},
"node_modules/limiter": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -8356,6 +8480,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -8439,6 +8569,34 @@
"yallist": "^3.0.2"
}
},
"node_modules/lru-memoizer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz",
"integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==",
"license": "MIT",
"dependencies": {
"lodash.clonedeep": "^4.5.0",
"lru-cache": "6.0.0"
}
},
"node_modules/lru-memoizer/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/lru-memoizer/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@ -9162,6 +9320,16 @@
"node": ">= 0.4.0"
}
},
"node_modules/passport-jwt": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
"passport-strategy": "^1.0.0"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",

View File

@ -21,22 +21,28 @@
},
"dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.1.7",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.7",
"@nestjs/jwt": "^11.0.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.7",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.4.0",
"axios": "^1.12.2",
"cache-manager": "^7.2.4",
"circuit-breaker-ts": "^0.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"helmet": "^8.1.0",
"jwks-rsa": "^3.2.0",
"jwt-decode": "^4.0.0",
"keycloak-connect": "^26.1.1",
"nest-keycloak-connect": "^1.10.1",
"passport": "^0.7.0",
"passport-http-bearer": "^1.0.1",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},

BIN
src.zip Normal file

Binary file not shown.

View File

@ -1,14 +1,37 @@
import { Controller, Get, Req, UseGuards, Logger } from '@nestjs/common';
import { ClientCredentialsGuard } from '../../auth/guards/client-credentials.guard';
import { Roles } from '../../decorators/roles.decorator';
import { Controller, Get, Logger } from '@nestjs/common';
import { AuthenticatedUser, Roles, Resource, Scopes } from 'nest-keycloak-connect';
import { RESOURCES } from '../../constants/resouces';
import { SCOPES } from '../../constants/scopes';
@Controller('api')
@UseGuards(ClientCredentialsGuard) // applique le guard à tout le controller
@Resource(RESOURCES.USER)
export class ApiController {
private readonly logger = new Logger(ApiController.name);
@Get('secure')
@Scopes(SCOPES.READ)
getSecure(@AuthenticatedUser() user: any) {
this.logger.log(`User ${user?.preferred_username} accessed /secure`);
return {
message: 'Accès autorisé',
user,
};
}
@Get('token-details')
@Scopes(SCOPES.READ)
tokenDetails(@AuthenticatedUser() user: any) {
return {
username: user.preferred_username,
client_id: user.client_id,
realmRoles: user.realm_access?.roles || [],
resourceRoles: user.resource_access?.[user.client_id]?.roles || [],
};
}
@Get('protected')
@Roles('DCB_ADMIN') // ex: uniquement les clients avec DCB_ADMIN peuvent accéder
@Scopes(SCOPES.READ)
getProtected() {
this.logger.log('Accessed protected route');
return {
@ -17,17 +40,8 @@ export class ApiController {
};
}
@Get('protected-data')
@Roles('DCB_MANAGER', 'DCB_ADMIN') // plusieurs rôles possibles
getProtectedData(@Req() request: Request) {
const token = request['accessToken']; // injecté par le ClientCredentialsGuard
this.logger.log('Accessing protected data with client credentials');
return {
message: 'This is protected data accessed using client credentials',
timestamp: new Date().toISOString(),
tokenPreview: token ? `${token.substring(0, 20)}...` : 'No token',
};
@Get('public')
getPublic() {
return { message: 'Accès public' };
}
}

View File

@ -41,10 +41,9 @@ import { UsersModule } from './users/users.module';
return {
authServerUrl: keycloakConfig.serverUrl,
realm: keycloakConfig.realm,
clientId: keycloakConfig.clientId,
secret: keycloakConfig.clientSecret,
clientId: keycloakConfig.authClientId,
secret: keycloakConfig.authClientSecret,
useNestLogger: true,
bearerOnly: true,
/**
* Validation OFFLINE :

View File

@ -7,6 +7,9 @@ import { KeycloakApiService } from './services/keycloak-api.service';
import { AuthController } from './controllers/auth.controller';
import { HealthController } from '../health/health.controller';
import { UsersService } from '../users/services/users.service';
import { KeycloakJwtStrategy } from './services/keycloak.strategy';
import { JwtAuthGuard } from './guards/jwt.guard';
@Module({
@ -14,8 +17,15 @@ import { UsersService } from '../users/services/users.service';
HttpModule,
JwtModule.register({}),
],
providers: [StartupService, TokenService, KeycloakApiService, UsersService],
providers: [
KeycloakJwtStrategy,
JwtAuthGuard,
StartupService,
TokenService,
KeycloakApiService,
UsersService
],
controllers: [AuthController, HealthController],
exports: [StartupService, TokenService, KeycloakApiService, UsersService, JwtModule],
exports: [JwtAuthGuard, StartupService, TokenService, KeycloakApiService, UsersService, JwtModule],
})
export class AuthModule {}

View File

@ -9,21 +9,13 @@ import {
HttpException,
HttpStatus,
} from '@nestjs/common';
import {
AuthenticatedUser,
Public,
Roles,
} from 'nest-keycloak-connect';
import { AuthenticatedUser, Public, Roles } from 'nest-keycloak-connect';
import { TokenService } from '../services/token.service';
import { KeycloakApiService } from '../services/keycloak-api.service';
import { ConfigService } from '@nestjs/config';
import type { Request } from 'express';
import { UsersService } from '../../users/services/users.service';
import * as user from '../../users/models/user';
interface LoginDto {
username: string;
password: string;
}
@Controller('auth')
export class AuthController {
@ -31,7 +23,6 @@ export class AuthController {
constructor(
private readonly tokenService: TokenService,
private readonly keycloakApiService: KeycloakApiService,
private readonly configService: ConfigService,
private readonly usersService: UsersService
) {}
@ -39,16 +30,14 @@ export class AuthController {
/** -------------------------------
* LOGIN (Resource Owner Password Credentials)
* ------------------------------- */
// === AUTHENTIFICATION ===
@Public()
@Post('login')
async login(
@Body() loginDto: LoginDto
): Promise<{
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
}> {
async login(@Body() loginDto: user.LoginDto) {
this.logger.log(`User login attempt: ${loginDto.username}`);
const { username, password } = loginDto;
if (!username || !password) {
@ -56,7 +45,8 @@ export class AuthController {
}
try {
const tokenResponse = await this.tokenService.acquireUserToken(username, password);
// Appel au UserService pour l'authentification
const tokenResponse = await this.usersService.authenticateUser(loginDto);
this.logger.log(`User "${username}" authenticated successfully`);
return {
@ -66,38 +56,21 @@ export class AuthController {
token_type: 'Bearer',
};
} catch (err: any) {
const errorMessage = err.message;
// Gestion spécifique des erreurs Keycloak
if (errorMessage.includes('Account is not fully set up')) {
const msg = err.message || '';
if (msg.includes('Account is not fully set up')) {
this.logger.warn(`User account not fully set up: "${username}"`);
throw new HttpException(
'Account setup incomplete. Please contact administrator.',
HttpStatus.FORBIDDEN
);
throw new HttpException('Account setup incomplete', HttpStatus.FORBIDDEN);
}
if (errorMessage.includes('Invalid user credentials')) {
if (msg.includes('Invalid user credentials')) {
this.logger.warn(`Invalid credentials for user: "${username}"`);
throw new HttpException(
'Invalid username or password',
HttpStatus.UNAUTHORIZED
);
throw new HttpException('Invalid username or password', HttpStatus.UNAUTHORIZED);
}
if (errorMessage.includes('User is disabled')) {
if (msg.includes('User is disabled')) {
this.logger.warn(`Disabled user attempted login: "${username}"`);
throw new HttpException(
'Account is disabled',
HttpStatus.FORBIDDEN
);
throw new HttpException('Account is disabled', HttpStatus.FORBIDDEN);
}
this.logger.warn(`Authentication failed for "${username}": ${errorMessage}`);
throw new HttpException(
'Authentication failed',
HttpStatus.UNAUTHORIZED
);
this.logger.warn(`Authentication failed for "${username}": ${msg}`);
throw new HttpException('Authentication failed', HttpStatus.UNAUTHORIZED);
}
}
@ -107,49 +80,41 @@ export class AuthController {
@Post('logout')
async logout(@Req() req: Request) {
const token = req.headers['authorization']?.split(' ')[1];
if (!token) {
throw new HttpException('No token provided', HttpStatus.BAD_REQUEST);
}
if (!token) throw new HttpException('No token provided', HttpStatus.BAD_REQUEST);
try {
const refreshToken = await this.tokenService.getStoredRefreshToken(token);
// Récupérer le refresh token depuis le UserService
const refreshToken = this.tokenService.getUserRefreshToken();
if (refreshToken) {
try {
// Appel au TokenService pour révoquer le token
await this.tokenService.revokeToken(refreshToken);
this.logger.log('Refresh token revoked successfully');
} catch (revokeError) {
this.logger.warn('Failed to revoke refresh token, continuing with local logout', revokeError);
}
}
// Nettoyer les tokens stockés dans UserService
await this.tokenService.clearUserToken();
this.logger.log(`User logged out successfully`);
return { message: 'Logout successful' };
} catch (error: any) {
this.logger.error('Logout failed', error);
} catch (err: any) {
this.logger.error('Logout failed', err);
// En cas d'erreur, nettoyer quand même les tokens dans UserService
await this.tokenService.clearUserToken();
if (err instanceof HttpException) {
throw err;
}
throw new HttpException('Logout failed', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/** -------------------------------
* GET CURRENT USER (from token)
* ------------------------------- */
@Get('me')
async getCurrentUser(@Req() req: Request, @AuthenticatedUser() user: any) {
const authHeader = req.headers['authorization'];
const token = authHeader?.replace('Bearer ', '').trim();
if (!token) throw new HttpException('Missing token', HttpStatus.BAD_REQUEST);
return this.usersService.getUserProfile(token);
}
/** -------------------------------
* GET USER BY ID
* ------------------------------- */
@Get('profile/:id')
@Roles({ roles: ['admin', 'viewer'] })
async getUserById(@Param('id') id: string) {
return this.usersService.getUserById(id);
}
/** -------------------------------
* REFRESH TOKEN
* ------------------------------- */
@ -157,22 +122,18 @@ export class AuthController {
@Post('refresh')
async refreshToken(@Body() body: { refresh_token: string }) {
const { refresh_token } = body;
if (!refresh_token) {
throw new HttpException('Refresh token is required', HttpStatus.BAD_REQUEST);
}
if (!refresh_token) throw new HttpException('Refresh token is required', HttpStatus.BAD_REQUEST);
try {
const tokenResponse = await this.tokenService.refreshToken(refresh_token);
return {
access_token: tokenResponse.access_token,
refresh_token: tokenResponse.refresh_token,
expires_in: tokenResponse.expires_in,
token_type: 'Bearer',
};
} catch (error: any) {
this.logger.error('Token refresh failed', error);
} catch (err: any) {
this.logger.error('Token refresh failed', err);
throw new HttpException('Invalid refresh token', HttpStatus.UNAUTHORIZED);
}
}
@ -183,21 +144,15 @@ export class AuthController {
@Public()
@Get('status')
async getAuthStatus(@Req() req: Request) {
const authHeader = req.headers['authorization'];
const token = req.headers['authorization']?.replace('Bearer ', '');
let isValid = false;
if (authHeader) {
if (token) {
try {
const token = authHeader.replace('Bearer ', '');
isValid = await this.tokenService.validateToken(token);
} catch (error) {
} catch {
this.logger.debug('Token validation failed in status check');
}
}
return {
authenticated: isValid,
status: isValid ? 'Token is valid' : 'Token is invalid or expired',
};
return { authenticated: isValid, status: isValid ? 'Token is valid' : 'Token is invalid or expired' };
}
}

View File

@ -1,56 +0,0 @@
import { Injectable, CanActivate, ExecutionContext, Logger, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { TokenService } from '../services/token.service';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class ClientCredentialsGuard implements CanActivate {
private readonly logger = new Logger(ClientCredentialsGuard.name);
constructor(
private readonly tokenService: TokenService,
private readonly jwtService: JwtService, // Injection du JwtService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
try {
// Récupère ou rafraîchit le token client
const token = await this.tokenService.getToken();
request['accessToken'] = token;
// Décodage JWT avec JwtService (ne vérifie pas la signature côté client)
const payload: any = this.jwtService.decode(token);
if (!payload) {
this.logger.warn('Token could not be decoded');
throw new UnauthorizedException('Invalid token');
}
const roles: string[] = payload.realm_access?.roles || [];
// Vérification facultative des rôles spécifiés via metadata
const requiredRoles = this.getRequiredRoles(context);
if (requiredRoles.length > 0) {
const hasRole = requiredRoles.some(role => roles.includes(role));
if (!hasRole) {
this.logger.warn(`Access denied, missing required roles: ${requiredRoles}`);
throw new ForbiddenException('Insufficient service role');
}
}
return true;
} catch (err: any) {
this.logger.error('Client credentials guard failed: ' + (err?.message ?? err));
if (err instanceof ForbiddenException) throw err;
throw new UnauthorizedException('Service authentication failed');
}
}
/**
* Récupère les rôles requis définis via un décorateur @Roles() sur le handler.
*/
private getRequiredRoles(context: ExecutionContext): string[] {
const handler = context.getHandler();
return Reflect.getMetadata('roles', handler) || [];
}
}

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@ -1,68 +0,0 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class UserAuthGuard implements CanActivate {
private readonly logger = new Logger(UserAuthGuard.name);
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
// Vérifie la présence du header Authorization
const authHeader = request.headers['authorization'];
if (!authHeader) {
throw new UnauthorizedException('Authorization header missing');
}
// Extraction du token
const [type, token] = authHeader.split(' ');
if (type !== 'Bearer' || !token) {
throw new UnauthorizedException('Invalid or missing Bearer token');
}
try {
// Récupère la clé publique Keycloak
const publicKey = this.configService.get<string>('keycloak.publicKey');
if (!publicKey) {
throw new Error('Keycloak public key not configured');
}
// Vérifie et décode le token
const payload = this.jwtService.verify(token, {
algorithms: ['RS256'],
publicKey: this.formatPublicKey(publicKey),
});
// Vérifie que le token appartient bien à ton realm Keycloak
const expectedIssuer = `${this.configService.get<string>('keycloak.serverUrl')}/realms/${this.configService.get<string>('keycloak.realm')}`;
if (payload.iss !== expectedIssuer) {
throw new UnauthorizedException('Invalid token issuer');
}
// Attache le payload utilisateur à la requête
request.user = payload;
return true;
} catch (err: any) {
this.logger.error(`UserAuthGuard failed: ${err?.message}`);
throw new UnauthorizedException('Invalid or expired token');
}
}
private formatPublicKey(key: string): string {
if (key.includes('BEGIN PUBLIC KEY')) return key;
return `-----BEGIN PUBLIC KEY-----\n${key}\n-----END PUBLIC KEY-----`;
}
}

View File

@ -1,385 +1,246 @@
import { Injectable, Logger, HttpException, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, HttpException, NotFoundException, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { AxiosResponse } from 'axios';
import { firstValueFrom, timeout as rxjsTimeout } from 'rxjs';
import { TokenService } from './token.service';
import jwtDecode from 'jwt-decode';
import { KeycloakConfig } from '../../config/keycloak.config';
import { firstValueFrom, Observable, timeout as rxjsTimeout } from 'rxjs';
import { TokenService } from './token.service'; // Import du TokenService
interface DecodedToken {
sub: string;
preferred_username?: string;
export interface KeycloakUser {
id?: string;
username: string;
email?: string;
given_name?: string;
family_name?: string;
realm_access?: { roles: string[] };
resource_access?: Record<string, { roles: string[] }>;
scope?: string;
firstName?: string;
lastName?: string;
enabled: boolean;
emailVerified: boolean;
attributes?: Record<string, any>;
}
export interface KeycloakRole {
id: string;
name: string;
description?: string;
}
export type ClientRole = 'admin' | 'merchant' | 'support';
@Injectable()
export class KeycloakApiService {
private readonly logger = new Logger(KeycloakApiService.name);
private readonly keycloakBaseUrl: string;
private readonly realm: string;
private readonly clientId: string;
constructor(
private readonly httpService: HttpService,
private readonly tokenService: TokenService,
private readonly configService: ConfigService,
private readonly tokenService: TokenService, // Injection du TokenService
) {
this.keycloakBaseUrl = this.configService.get<string>('keycloak.serverUrl')
|| 'https://keycloak-dcb.app.cameleonapp.com';
this.keycloakBaseUrl = this.configService.get<string>('KEYCLOAK_SERVER_URL') || 'http://localhost:8080';
this.realm = this.configService.get<string>('KEYCLOAK_REALM') || 'master';
this.clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID') || 'admin-cli';
}
async request<T>(
// ===== MÉTHODE POUR L'AUTHENTIFICATION UTILISATEUR =====
async authenticateUser(username: string, password: string) {
return this.tokenService.acquireUserToken(username, password);
}
// ===== CORE REQUEST METHOD (pour opérations admin) =====
private async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
url: string,
data?: any,
opts?: { timeoutMs?: number },
path: string,
data?: any
): Promise<T> {
const token = await this.tokenService.getToken();
const token = await this.tokenService.acquireServiceAccountToken();
const url = `${this.keycloakBaseUrl}${path}`;
const config = {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
timeout: 10000,
};
try {
let obs;
const timeoutMs = opts?.timeoutMs ?? 5000;
let response: AxiosResponse<T>;
switch (method) {
case 'GET':
obs = this.httpService.get<T>(url, config);
response = await firstValueFrom(this.httpService.get<T>(url, config).pipe(rxjsTimeout(10000)));
break;
case 'POST':
obs = this.httpService.post<T>(url, data, config);
response = await firstValueFrom(this.httpService.post<T>(url, data, config).pipe(rxjsTimeout(10000)));
break;
case 'PUT':
obs = this.httpService.put<T>(url, data, config);
response = await firstValueFrom(this.httpService.put<T>(url, data, config).pipe(rxjsTimeout(10000)));
break;
case 'DELETE':
obs = this.httpService.delete<T>(url, config);
response = await firstValueFrom(this.httpService.delete<T>(url, config).pipe(rxjsTimeout(10000)));
break;
default:
throw new Error(`Unsupported HTTP method: ${method}`);
throw new BadRequestException(`Unsupported HTTP method: ${method}`);
}
const response: AxiosResponse<T> = await firstValueFrom(obs.pipe(rxjsTimeout(timeoutMs)));
return response.data;
} catch (err: unknown) {
const error = err as any;
const urlWithoutToken = this.sanitizeUrl(url);
let errorMessage = `Request to ${urlWithoutToken} failed`;
if (error?.response) {
errorMessage += `: ${error.response.status} ${error.response.statusText}`;
// Gestion spécifique des erreurs 404
if (error.response.status === 404) {
throw new NotFoundException(`Resource not found: ${urlWithoutToken}`);
}
this.logger.error(`HTTP Error for ${method} ${urlWithoutToken}`, {
status: error.response.status,
data: error.response.data,
});
} else if (error?.message) {
errorMessage += `: ${error.message}`;
this.logger.error(`Network error for ${method} ${urlWithoutToken}`);
} else {
errorMessage += `: Unknown error`;
this.logger.error(`Unknown error for ${method} ${urlWithoutToken}`);
}
throw new HttpException(errorMessage, error?.response?.status || 500);
}
}
private sanitizeUrl(url: string): string {
return url.replace(/\/[0-9a-fA-F-]{36}\//g, '/***/');
}
private buildKeycloakUrl(path: string): string {
return `${this.keycloakBaseUrl}${path.startsWith('/') ? path : `/${path}`}`;
}
// === REALM OPERATIONS ===
async getRealmClients(realm: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/clients`);
return this.request<any[]>('GET', url);
}
async getRealmInfo(realm: string): Promise<any> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}`);
return this.request<any>('GET', url);
}
// === USER OPERATIONS ===
async getUserById(realm: string, userId: string): Promise<any> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}`);
return this.request<any>('GET', url);
}
private decodeJwt<T>(token: string): T {
if (!token) throw new Error('Token is required');
const payload = token.split('.')[1];
if (!payload) throw new Error('Invalid JWT token');
const decodedJson = Buffer.from(payload, 'base64').toString('utf-8');
return JSON.parse(decodedJson) as T;
}
/**
* Récupère le profil utilisateur.
* Offline validation décodage JWT
* Fallback Keycloak /userinfo
*/
async getUserProfile(realm: string, accessToken: string): Promise<any> {
// --- 1. Décodage du token (offline) ---
try {
const decoded: DecodedToken = this.decodeJwt<DecodedToken>(accessToken);
if (!decoded?.sub) throw new HttpException('Invalid token', 401);
const resourceRoles = decoded.resource_access?.['dcb-user-service-pwd']?.roles || [];
const realmRoles = decoded.realm_access?.roles || [];
return {
sub: decoded.sub,
username: decoded.preferred_username,
email: decoded.email,
given_name: decoded.given_name,
family_name: decoded.family_name,
roles: [...realmRoles, ...resourceRoles],
scope: decoded.scope,
};
} catch (error: any) {
this.logger.warn(`Offline token decoding failed: ${error.message}. Falling back to /userinfo.`);
this.handleRequestError(error, path);
}
}
// --- 2. Fallback : Appel Keycloak /userinfo ---
try {
const url = `${this.keycloakBaseUrl}/realms/${realm}/protocol/openid-connect/userinfo`;
const response = await firstValueFrom(
this.httpService.get(url, {
headers: { Authorization: `Bearer ${accessToken}` },
}),
// ===== ERROR HANDLING =====
private handleRequestError(error: any, context: string): never {
if (error.response?.status === 404) {
throw new NotFoundException(`Resource not found: ${context}`);
}
if (error.response?.status === 409) {
throw new BadRequestException('User already exists');
}
this.logger.error(`Keycloak API error in ${context}: ${error.message}`, {
status: error.response?.status,
});
throw new HttpException(
error.response?.data?.errorMessage || 'Keycloak operation failed',
error.response?.status || 500
);
}
return response.data;
} catch (error: any) {
const status = error.response?.status || 500;
const data = error.response?.data || error.message;
// ===== USER CRUD OPERATIONS =====
async createUser(userData: {
username: string;
email: string;
firstName: string;
lastName: string;
password: string;
enabled?: boolean;
}): Promise<string> {
const userPayload = {
username: userData.username,
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
enabled: userData.enabled ?? true,
emailVerified: true,
credentials: [{
type: 'password',
value: userData.password,
temporary: false,
}],
};
this.logger.error(`Failed to fetch user profile from Keycloak: ${status}`, data);
if (status === 401) throw new HttpException('Invalid or expired token', 401);
await this.request('POST', `/admin/realms/${this.realm}/users`, userPayload);
throw new HttpException('Failed to fetch user profile', status);
const users = await this.findUserByUsername(userData.username);
if (users.length === 0) {
throw new Error('Failed to create user');
}
return users[0].id!;
}
async getUserById(userId: string): Promise<KeycloakUser> {
return this.request('GET', `/admin/realms/${this.realm}/users/${userId}`);
}
async getAllUsers(): Promise<KeycloakUser[]> {
return this.request('GET', `/admin/realms/${this.realm}/users`);
}
async findUserByUsername(username: string): Promise<KeycloakUser[]> {
return this.request('GET', `/admin/realms/${this.realm}/users?username=${encodeURIComponent(username)}`);
}
async findUserByEmail(email: string): Promise<KeycloakUser[]> {
return this.request('GET', `/admin/realms/${this.realm}/users?email=${encodeURIComponent(email)}`);
}
async updateUser(userId: string, userData: Partial<KeycloakUser>): Promise<void> {
return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, userData);
}
async deleteUser(userId: string): Promise<void> {
return this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}`);
}
// ===== CLIENT ROLE OPERATIONS =====
async getUserClientRoles(userId: string): Promise<KeycloakRole[]> {
const clients = await this.getClient();
return this.request('GET', `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`);
}
async assignClientRole(userId: string, role: ClientRole): Promise<void> {
const clients = await this.getClient();
const targetRole = await this.getRole(role, clients[0].id);
return this.request('POST', `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`, [targetRole]);
}
async removeClientRole(userId: string, role: ClientRole): Promise<void> {
const clients = await this.getClient();
const targetRole = await this.getRole(role, clients[0].id);
return this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`, [targetRole]);
}
async setClientRoles(userId: string, roles: ClientRole[]): Promise<void> {
const currentRoles = await this.getUserClientRoles(userId);
if (currentRoles.length > 0) {
const clients = await this.getClient();
await this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`, currentRoles);
}
for (const role of roles) {
await this.assignClientRole(userId, role);
}
}
async getUsers(realm: string, queryParams?: {
briefRepresentation?: boolean;
email?: string;
first?: number;
firstName?: string;
lastName?: string;
max?: number;
search?: string;
username?: string;
}): Promise<any[]> {
let url = this.buildKeycloakUrl(`/admin/realms/${realm}/users`);
// Ajouter les paramètres de query s'ils sont fournis
if (queryParams) {
const params = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
params.append(key, value.toString());
}
});
const queryString = params.toString();
if (queryString) {
url += `?${queryString}`;
// ===== UTILITY METHODS =====
async userExists(username: string): Promise<boolean> {
try {
const users = await this.findUserByUsername(username);
return users.length > 0;
} catch {
return false;
}
}
return this.request<any[]>('GET', url);
async enableUser(userId: string): Promise<void> {
await this.updateUser(userId, { enabled: true });
}
async createUser(realm: string, user: any): Promise<any> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users`);
return this.request<any>('POST', url, user);
async disableUser(userId: string): Promise<void> {
await this.updateUser(userId, { enabled: false });
}
async updateUser(realm: string, userId: string, data: any): Promise<any> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}`);
return this.request<any>('PUT', url, data);
}
async deleteUser(realm: string, userId: string): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}`);
await this.request<void>('DELETE', url);
}
// === ROLE OPERATIONS ===
async getRealmRoles(realm: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/roles`);
return this.request<any[]>('GET', url);
}
async getUserRealmRoles(realm: string, userId: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/realm`);
return this.request<any[]>('GET', url);
}
async assignRealmRoles(realm: string, userId: string, roles: any[]): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/realm`);
// S'assurer que les rôles sont au format attendu par Keycloak
const rolesToAssign = roles.map(role => {
if (typeof role === 'string') {
return { id: role, name: role };
}
return role;
});
return this.request<void>('POST', url, rolesToAssign);
}
async removeRealmRoles(realm: string, userId: string, roles: any[]): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/realm`);
// S'assurer que les rôles sont au format attendu par Keycloak
const rolesToRemove = roles.map(role => {
if (typeof role === 'string') {
return { id: role, name: role };
}
return role;
});
return this.request<void>('DELETE', url, rolesToRemove);
}
// === PASSWORD OPERATIONS ===
async resetPassword(realm: string, userId: string, newPassword: string, temporary: boolean = true): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/reset-password`);
async resetPassword(userId: string, newPassword: string): Promise<void> {
const credentials = {
type: 'password',
value: newPassword,
temporary: temporary,
temporary: false,
};
return this.request<void>('PUT', url, credentials);
return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}/reset-password`, credentials);
}
// === GROUP OPERATIONS ===
async getUserGroups(realm: string, userId: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/groups`);
return this.request<any[]>('GET', url);
// ===== PRIVATE HELPERS =====
private async getClient(): Promise<any[]> {
const clients = await this.request<any[]>('GET', `/admin/realms/${this.realm}/clients?clientId=${this.clientId}`);
if (!clients || clients.length === 0) {
throw new Error('Client not found');
}
return clients;
}
async addUserToGroup(realm: string, userId: string, groupId: string): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/groups/${groupId}`);
return this.request<void>('PUT', url);
private async getRole(role: ClientRole, clientId: string): Promise<KeycloakRole> {
const roles = await this.request<KeycloakRole[]>('GET', `/admin/realms/${this.realm}/clients/${clientId}/roles`);
const targetRole = roles.find(r => r.name === role);
if (!targetRole) {
throw new BadRequestException(`Role '${role}' not found`);
}
async removeUserFromGroup(realm: string, userId: string, groupId: string): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/groups/${groupId}`);
return this.request<void>('DELETE', url);
return targetRole;
}
// === CLIENT OPERATIONS ===
async getClientRoles(realm: string, clientId: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/clients/${clientId}/roles`);
return this.request<any[]>('GET', url);
}
async getUserClientRoles(realm: string, userId: string, clientId: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/role-mappings/clients/${clientId}`);
return this.request<any[]>('GET', url);
}
// === SESSION OPERATIONS ===
async getUserSessions(realm: string, userId: string): Promise<any[]> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/sessions`);
return this.request<any[]>('GET', url);
}
async logoutUser(realm: string, userId: string): Promise<void> {
const url = this.buildKeycloakUrl(`/admin/realms/${realm}/users/${userId}/logout`);
return this.request<void>('POST', url);
}
// === SEARCH OPERATIONS ===
async searchUsers(realm: string, search: string, maxResults: number = 50): Promise<any[]> {
const users = await this.getUsers(realm, { search, max: maxResults });
return users;
}
async getUserByUsername(realm: string, username: string): Promise<any> {
const users = await this.getUsers(realm, { username, max: 1 });
return users.length > 0 ? users[0] : null;
}
async getUserByEmail(realm: string, email: string): Promise<any> {
const users = await this.getUsers(realm, { email, max: 1 });
return users.length > 0 ? users[0] : null;
}
// === BULK OPERATIONS ===
async updateUserAttributes(realm: string, userId: string, attributes: Record<string, any>): Promise<void> {
const user = await this.getUserById(realm, userId);
const updateData = {
...user,
attributes: {
...user.attributes,
...attributes,
},
};
await this.updateUser(realm, userId, updateData);
}
async disableUser(realm: string, userId: string): Promise<void> {
await this.updateUser(realm, userId, { enabled: false });
}
async enableUser(realm: string, userId: string): Promise<void> {
await this.updateUser(realm, userId, { enabled: true });
}
// === HEALTH CHECK ===
async checkHealth(): Promise<{ status: string; realm: string }> {
try {
const realm = this.configService.get<string>('keycloak.realm');
if (!realm) {
throw new Error('Keycloak configuration not found');
}
// Test basique de connexion
await this.getRealmInfo(realm);
return {
status: 'healthy',
realm
};
} catch (error) {
this.logger.error('Keycloak health check failed', error);
return {
status: 'unhealthy',
realm: 'unknown'
};
}
}
}

View File

@ -0,0 +1,56 @@
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import * as jwksRsa from 'jwks-rsa';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class KeycloakJwtStrategy extends PassportStrategy(Strategy, 'jwt') {
private readonly logger = new Logger(KeycloakJwtStrategy.name);
constructor(private configService: ConfigService) {
const jwksUri = configService.get<string>('KEYCLOAK_JWKS_URI');
const issuer = configService.get<string>('KEYCLOAK_ISSUER');
if (!jwksUri || !issuer) {
throw new Error('Missing Keycloak configuration (KEYCLOAK_JWKS_URI / KEYCLOAK_ISSUER)');
}
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKeyProvider: jwksRsa.passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri,
}),
issuer,
algorithms: ['RS256'],
});
}
async validate(payload: any) {
// Récupération des rôles du realm
const realmRoles: string[] = payload.realm_access?.roles || [];
// Récupération des rôles du client dans resource_access
const clientId = payload.client_id;
const resourceRoles: string[] =
payload.resource_access?.[clientId]?.roles || [];
// Fusion et suppression des doublons
const roles = Array.from(new Set([...realmRoles, ...resourceRoles]));
this.logger.verbose(`User ${payload.preferred_username} roles: ${JSON.stringify(roles)}`);
return {
sub: payload.sub,
preferred_username: payload.preferred_username,
email: payload.email,
client_id: clientId,
roles,
realmRoles,
resourceRoles,
};
}
}

View File

@ -1,30 +1,162 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { TokenService } from './token.service';
import { ConfigService } from '@nestjs/config';
import { KeycloakApiService } from './keycloak-api.service';
@Injectable()
export class StartupService implements OnModuleInit {
private readonly logger = new Logger(StartupService.name);
private isInitialized = false;
private initializationError: string | null = null;
private userToken: string | null = null;
constructor(
private readonly tokenService: TokenService,
private readonly keycloakApiService: KeycloakApiService,
private readonly configService: ConfigService,
) {}
async onModuleInit() {
this.logger.log('Starting Keycloak connection...');
this.logger.log('Starting Keycloak connection tests...');
const username = this.configService.get<string>('KEYCLOAK_TEST_USER_ADMIN');
const password = this.configService.get<string>('KEYCLOAK_TEST_PASSWORD_ADMIN');
if (!username || !password) {
this.initializationError = 'Test username/password not configured';
this.logger.error(this.initializationError);
return;
}
try {
// Test simple : acquisition du token admin
await this.tokenService.getToken();
// 1. Test d'authentification utilisateur
const tokenResponse = await this.keycloakApiService.authenticateUser(username, password);
this.userToken = tokenResponse.access_token;
this.logger.log('✅ User authentication test passed');
// 2. Test des opérations CRUD admin
//await this.testAdminOperations();
// 3. Test des opérations avec le nouveau mot de passe
//await this.testUserOperationsWithNewPassword();
this.isInitialized = true;
this.logger.log('✅ Keycloak connection established successfully');
this.logger.log('✅ All CRUD operations tested successfully');
} catch (error: any) {
this.initializationError = error.message || error;
this.logger.error('❌ Keycloak connection or method test failed', error);
}
}
private async testAdminOperations(): Promise<void> {
this.logger.log('Testing admin CRUD operations...');
let testUserId = '';
const usernameTest = 'startup-test-user';
// Vérifier si l'utilisateur existe
let testUser = await this.keycloakApiService.findUserByUsername(usernameTest);
if (!testUser || testUser.length === 0) {
// Créer l'utilisateur si inexistant
const newUser = {
username: usernameTest,
email: 'startup-test@example.com',
firstName: 'Startup',
lastName: 'Test',
password: 'TempPassword123!',
enabled: true,
};
testUserId = await this.keycloakApiService.createUser(newUser);
this.logger.log(`✅ Test user "${usernameTest}" created with ID: ${testUserId}`);
} else {
testUserId = testUser[0].id!;
this.logger.log(`✅ Using existing test user ID: ${testUserId}`);
}
if (!testUserId) {
throw new Error('Unable to create or fetch test user');
}
// Récupérer l'utilisateur par ID
const userById = await this.keycloakApiService.getUserById(testUserId);
this.logger.log(`✅ User fetched by ID: ${userById.username}`);
// Modifier l'utilisateur
await this.keycloakApiService.updateUser(testUserId, { firstName: 'UpdatedStartup' });
this.logger.log(`✅ User updated: ${testUserId}`);
// Opérations sur les rôles
const userRoles = await this.keycloakApiService.getUserClientRoles(testUserId);
this.logger.log(`✅ User client roles: ${userRoles.length}`);
// Assigner un rôle client
await this.keycloakApiService.assignClientRole(testUserId, 'merchant');
this.logger.log(`✅ Assigned merchant role to user: ${testUserId}`);
// Vérifier les rôles après assignation
const rolesAfterAssign = await this.keycloakApiService.getUserClientRoles(testUserId);
this.logger.log(`✅ User roles after assignment: ${rolesAfterAssign.map(r => r.name).join(', ')}`);
// Retirer le rôle
await this.keycloakApiService.removeClientRole(testUserId, 'merchant');
this.logger.log(`✅ Removed merchant role from user: ${testUserId}`);
// Tester l'assignation multiple de rôles
await this.keycloakApiService.setClientRoles(testUserId, ['admin', 'support']);
this.logger.log(`✅ Set multiple roles for user: admin, support`);
// Vérifier les rôles après assignation multiple
const finalRoles = await this.keycloakApiService.getUserClientRoles(testUserId);
this.logger.log(`✅ Final user roles: ${finalRoles.map(r => r.name).join(', ')}`);
// Réinitialisation du mot de passe
const newPassword = 'NewStartupPass123!';
await this.keycloakApiService.resetPassword(testUserId, newPassword);
this.logger.log(`✅ Password reset for user: ${testUserId}`);
// Tester enable/disable user
await this.keycloakApiService.disableUser(testUserId);
this.logger.log(`✅ User disabled: ${testUserId}`);
// Vérifier que l'utilisateur est désactivé
const disabledUser = await this.keycloakApiService.getUserById(testUserId);
this.logger.log(`✅ User enabled status: ${disabledUser.enabled}`);
// Réactiver l'utilisateur
await this.keycloakApiService.enableUser(testUserId);
this.logger.log(`✅ User enabled: ${testUserId}`);
// Tester la recherche d'utilisateurs
const userByEmail = await this.keycloakApiService.findUserByEmail('startup-test@example.com');
this.logger.log(`✅ User found by email: ${userByEmail ? userByEmail[0]?.username : 'none'}`);
// Vérifier si l'utilisateur existe
const userExists = await this.keycloakApiService.userExists(usernameTest);
this.logger.log(`✅ User exists check: ${userExists}`);
// Récupérer tous les utilisateurs
const allUsers = await this.keycloakApiService.getAllUsers();
this.logger.log(`✅ All users count: ${allUsers.length}`);
// Cleanup optionnel - Supprimer l'utilisateur de test
// await this.keycloakApiService.deleteUser(testUserId);
// this.logger.log(`✅ Test user deleted: ${testUserId}`);
}
private async testUserOperationsWithNewPassword(): Promise<void> {
this.logger.log('Testing user operations with new password...');
const usernameTest = 'startup-test-user';
const newPassword = 'NewStartupPass123!';
try {
// Tester la reconnexion avec le nouveau mot de passe
const newTokenResponse = await this.keycloakApiService.authenticateUser(usernameTest, newPassword);
this.logger.log(`✅ Test user reconnected successfully with new password`);
this.logger.log(`✅ New token acquired for test user`);
} catch (error) {
this.initializationError = error.message;
this.logger.error('❌ Keycloak connection failed', error);
// On ne throw pas l'erreur pour permettre à l'app de démarrer
this.logger.warn(`⚠️ Test user reconnection failed: ${error.message}`);
}
}
@ -33,15 +165,20 @@ export class StartupService implements OnModuleInit {
status: this.isInitialized ? 'healthy' : 'unhealthy',
keycloak: {
connected: this.isInitialized,
realm: this.configService.get('keycloak.realm'),
serverUrl: this.configService.get('keycloak.serverUrl'),
realm: this.configService.get('KEYCLOAK_REALM'),
serverUrl: this.configService.get('KEYCLOAK_SERVER_URL'),
},
timestamp: new Date(),
error: this.initializationError,
userToken: this.userToken ? 'available' : 'none',
};
}
isHealthy(): boolean {
return this.isInitialized;
}
getUserToken(): string | null {
return this.userToken;
}
}

View File

@ -7,7 +7,6 @@ import * as jwt from 'jsonwebtoken';
export interface KeycloakTokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
@ -17,101 +16,72 @@ export interface KeycloakTokenResponse {
@Injectable()
export class TokenService {
private readonly logger = new Logger(TokenService.name);
private readonly keycloakConfig: KeycloakConfig;
private currentToken: string | null = null;
private tokenExpiry: Date | null = null;
// Cache pour le token de service account
private serviceAccountToken: string | null = null;
private serviceTokenExpiry: number = 0;
// === TOKEN STORAGE ===
private userToken: string | null = null;
private userTokenExpiry: Date | null = null;
private userRefreshToken: string | null = null;
constructor(
private configService: ConfigService,
private httpService: HttpService,
) {}
// === POUR L'API ADMIN (KeycloakApiService) - Client Credentials ===
async getToken(): Promise<string> {
// Si nous avons un token valide, le retourner
if (this.currentToken && this.isTokenValid()) {
return this.currentToken;
private readonly configService: ConfigService,
private readonly httpService: HttpService,
) {
this.keycloakConfig = this.getKeycloakConfig();
}
// Sinon, acquérir un nouveau token en utilisant client_credentials
return await this.acquireClientCredentialsToken();
}
private async acquireClientCredentialsToken(): Promise<string> {
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
if (!keycloakConfig) {
// === CONFIGURATION ===
private getKeycloakConfig(): KeycloakConfig {
const config = this.configService.get<KeycloakConfig>('keycloak');
if (!config) {
throw new Error('Keycloak configuration not found');
}
const tokenEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`;
const params = new URLSearchParams();
params.append('grant_type', 'client_credentials');
params.append('client_id', keycloakConfig.adminClientId); // ← Client admin
params.append('client_secret', keycloakConfig.adminClientSecret); // ← Secret admin
try {
const response = await firstValueFrom(
this.httpService.post<KeycloakTokenResponse>(tokenEndpoint, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
);
// Stocker le token et sa date d'expiration
this.currentToken = response.data.access_token;
this.tokenExpiry = new Date(Date.now() + (response.data.expires_in * 1000));
this.logger.log('Successfully acquired client credentials token');
return this.currentToken;
} catch (error: any) {
this.logger.error('Failed to acquire client token', error.response?.data);
throw new Error(error.response?.data?.error_description || 'Failed to acquire client token');
}
return config;
}
private isTokenValid(): boolean {
if (!this.currentToken || !this.tokenExpiry) {
return false;
private getTokenEndpoint(): string {
return `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}/protocol/openid-connect/token`;
}
// Ajouter un buffer de sécurité (30 secondes par défaut)
const bufferSeconds = this.configService.get<number>('keycloak.tokenBufferSeconds') || 30;
const bufferMs = bufferSeconds * 1000;
// === CACHE MANAGEMENT ===
private isServiceTokenValid(): boolean {
if (!this.serviceAccountToken) return false;
return this.tokenExpiry.getTime() > (Date.now() + bufferMs);
const bufferMs = 30000; // 30 seconds buffer
return Date.now() < this.serviceTokenExpiry - bufferMs;
}
// === POUR L'AUTHENTIFICATION UTILISATEUR (AuthController) - Password Grant ===
private setServiceToken(token: string, expiresIn: number): void {
this.serviceAccountToken = token;
this.serviceTokenExpiry = Date.now() + (expiresIn * 1000);
}
// === TOKEN ACQUISITION ===
async acquireUserToken(username: string, password: string): Promise<KeycloakTokenResponse> {
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
if (!keycloakConfig) {
throw new Error('Keycloak configuration not found');
}
const tokenEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`;
const params = new URLSearchParams();
params.append('grant_type', 'password');
params.append('client_id', keycloakConfig.authClientId); // ← Client auth
params.append('client_secret', keycloakConfig.authClientSecret); // ← Secret auth
params.append('username', username);
params.append('password', password);
const params = new URLSearchParams({
grant_type: 'password',
client_id: this.keycloakConfig.authClientId,
client_secret: this.keycloakConfig.authClientSecret,
username,
password,
});
try {
const response = await firstValueFrom(
this.httpService.post<KeycloakTokenResponse>(tokenEndpoint, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
this.httpService.post<KeycloakTokenResponse>(this.getTokenEndpoint(), params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
);
this.logger.log(`User token acquired successfully for: ${username}`);
// Stocker le token et ses métadonnées
this.storeUserToken(response.data);
this.logger.log(`User token acquired for: ${username}`);
return response.data;
} catch (error: any) {
this.logger.error('Failed to acquire user token', error.response?.data);
@ -119,30 +89,161 @@ export class TokenService {
}
}
async refreshToken(refreshToken: string): Promise<KeycloakTokenResponse> {
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
// === TOKEN STORAGE METHOD ===
private storeUserToken(tokenResponse: KeycloakTokenResponse): void {
this.userToken = tokenResponse.access_token;
this.userRefreshToken = tokenResponse.refresh_token || null;
if (!keycloakConfig) {
throw new Error('Keycloak configuration not found');
// Calculer la date d'expiration (timestamp actuel + expires_in en secondes)
const expiresInMs = tokenResponse.expires_in * 1000;
this.userTokenExpiry = new Date(Date.now() + expiresInMs);
this.logger.log('User token stored successfully');
}
const tokenEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token`;
// === GET USER TOKEN ===
getUserToken(): string | null {
if (this.isUserTokenValid()) {
return this.userToken;
}
const params = new URLSearchParams();
params.append('grant_type', 'refresh_token');
params.append('client_id', keycloakConfig.authClientId); // ← Utiliser le client auth pour le refresh
params.append('client_secret', keycloakConfig.authClientSecret);
params.append('refresh_token', refreshToken);
this.logger.warn('User token is expired or invalid');
// Optionnel : tenter un rafraîchissement automatique ici
return null;
}
// === TOKEN VALIDATION ===
private isUserTokenValid(): boolean {
if (!this.userToken || !this.userTokenExpiry) {
return false;
}
// Ajouter une marge de sécurité (30 secondes) pour éviter les tokens sur le point d'expirer
const safetyMargin = 30 * 1000; // 30 secondes en millisecondes
return new Date() < new Date(this.userTokenExpiry.getTime() - safetyMargin);
}
// === REFRESH TOKEN ===
async refreshUserToken(): Promise<KeycloakTokenResponse | null> {
if (!this.userRefreshToken) {
this.logger.warn('No refresh token available');
return null;
}
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: this.keycloakConfig.authClientId,
client_secret: this.keycloakConfig.authClientSecret,
refresh_token: this.userRefreshToken,
});
try {
const response = await firstValueFrom(
this.httpService.post<KeycloakTokenResponse>(tokenEndpoint, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
this.httpService.post<KeycloakTokenResponse>(this.getTokenEndpoint(), params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
);
// Mettre à jour le token stocké
this.storeUserToken(response.data);
this.logger.log('User token refreshed successfully');
return response.data;
} catch (error: any) {
this.logger.error('Failed to refresh user token', error.response?.data);
this.clearUserToken(); // Nettoyer les tokens invalides
throw new Error(error.response?.data?.error_description || 'Token refresh failed');
}
}
// === CLEAR USER TOKEN ===
clearUserToken(): void {
this.userToken = null;
this.userTokenExpiry = null;
this.userRefreshToken = null;
this.logger.log('User token cleared');
}
// === CHECK TOKEN STATUS ===
isUserAuthenticated(): boolean {
return this.isUserTokenValid();
}
// === GET TOKEN EXPIRY ===
getUserTokenExpiry(): Date | null {
return this.userTokenExpiry;
}
// === GET REFRESH TOKEN ===
getUserRefreshToken(): string | null {
return this.userRefreshToken;
}
// === GET TOKEN WITH AUTO-REFRESH ===
async getValidUserToken(): Promise<string | null> {
// Si le token est valide, le retourner
if (this.isUserTokenValid()) {
return this.userToken;
}
// Si le token a expiré mais on a un refresh token, tenter de le rafraîchir
if (this.userRefreshToken) {
try {
const newToken = await this.refreshUserToken();
return newToken?.access_token || null;
} catch (error) {
this.logger.error('Auto-refresh failed, user needs to reauthenticate');
return null;
}
}
return null;
}
async acquireServiceAccountToken(): Promise<string> {
if (this.isServiceTokenValid()) {
return this.serviceAccountToken!;
}
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.keycloakConfig.authClientId,
client_secret: this.keycloakConfig.authClientSecret,
});
try {
const response = await firstValueFrom(
this.httpService.post<KeycloakTokenResponse>(this.getTokenEndpoint(), params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
);
this.setServiceToken(response.data.access_token, response.data.expires_in);
this.logger.log('Service account token acquired');
return this.serviceAccountToken!;
} catch (error: any) {
this.logger.error('Failed to acquire service account token', error.response?.data);
throw new Error('Service account authentication failed');
}
}
async refreshToken(refreshToken: string): Promise<KeycloakTokenResponse> {
const params = new URLSearchParams({
grant_type: 'refresh_token',
client_id: this.keycloakConfig.authClientId,
client_secret: this.keycloakConfig.authClientSecret,
refresh_token: refreshToken,
});
try {
const response = await firstValueFrom(
this.httpService.post<KeycloakTokenResponse>(this.getTokenEndpoint(), params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
);
this.logger.log('Token refreshed successfully');
return response.data;
} catch (error: any) {
this.logger.error('Token refresh failed', error.response?.data);
@ -150,28 +251,83 @@ export class TokenService {
}
}
async revokeToken(token: string): Promise<void> {
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
// === TOKEN VALIDATION ===
async validateToken(token: string): Promise<boolean> {
const mode = this.keycloakConfig.validationMode || 'online';
if (!keycloakConfig) {
throw new Error('Keycloak configuration not found');
return mode === 'offline'
? this.validateOffline(token)
: this.validateOnline(token);
}
const revokeEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/revoke`;
private async validateOnline(token: string): Promise<boolean> {
const params = new URLSearchParams({
client_id: this.keycloakConfig.authClientId,
client_secret: this.keycloakConfig.authClientSecret,
token,
});
const params = new URLSearchParams();
// Utiliser le client auth pour la révocation (car c'est généralement lié aux tokens utilisateur)
params.append('client_id', keycloakConfig.authClientId);
params.append('client_secret', keycloakConfig.authClientSecret);
params.append('token', token);
try {
const response = await firstValueFrom(
this.httpService.post(
`${this.getTokenEndpoint()}/introspect`,
params,
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
)
);
return response.data.active === true;
} catch (error: any) {
this.logger.error('Online token validation failed', error.response?.data);
return false;
}
}
private validateOffline(token: string): boolean {
if (!this.keycloakConfig.publicKey) {
this.logger.error('Missing public key for offline validation');
return false;
}
try {
const formattedKey = `-----BEGIN PUBLIC KEY-----\n${this.keycloakConfig.publicKey}\n-----END PUBLIC KEY-----`;
jwt.verify(token, formattedKey, {
algorithms: ['RS256'],
audience: this.keycloakConfig.authClientId,
});
return true;
} catch (error: any) {
this.logger.error('Offline token validation failed:', error.message);
return false;
}
}
// === TOKEN UTILITIES ===
decodeToken(token: string): any {
try {
return jwt.decode(token);
} catch (error: any) {
this.logger.error('Failed to decode token', error.message);
throw new Error('Invalid token format');
}
}
async revokeToken(token: string): Promise<void> {
const params = new URLSearchParams({
client_id: this.keycloakConfig.authClientId,
client_secret: this.keycloakConfig.authClientSecret,
token,
});
try {
await firstValueFrom(
this.httpService.post(revokeEndpoint, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
this.httpService.post(
`${this.getTokenEndpoint()}/revoke`,
params,
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
)
);
this.logger.log('Token revoked successfully');
@ -181,89 +337,25 @@ export class TokenService {
}
}
async validateOffline(token: string): Promise<boolean> {
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
if (!keycloakConfig?.publicKey) {
this.logger.error('Missing Keycloak public key for offline validation');
return false;
// === SERVICE MANAGEMENT ===
clearServiceToken(): void {
this.serviceAccountToken = null;
this.serviceTokenExpiry = 0;
this.logger.log('Service account token cleared');
}
try {
const formattedKey = `-----BEGIN PUBLIC KEY-----\n${keycloakConfig.publicKey}\n-----END PUBLIC KEY-----`;
jwt.verify(token, formattedKey, {
algorithms: ['RS256'],
audience: keycloakConfig.authClientId,
});
return true;
} catch (err) {
this.logger.error('Offline token validation failed:', err.message);
return false;
}
getServiceTokenInfo(): {
hasToken: boolean;
expiresIn?: number;
} {
if (!this.serviceAccountToken) {
return { hasToken: false };
}
async validateToken(token: string): Promise<boolean> {
const mode = this.configService.get<string>('keycloak.validationMode') || 'online';
if (mode === 'offline') {
return this.validateOffline(token);
} else {
return this.validateOnline(token);
}
}
private async validateOnline(token: string): Promise<boolean> {
const keycloakConfig = this.configService.get<KeycloakConfig>('keycloak');
if (!keycloakConfig) {
throw new Error('Keycloak configuration not found');
}
const introspectEndpoint = `${keycloakConfig.serverUrl}/realms/${keycloakConfig.realm}/protocol/openid-connect/token/introspect`;
const params = new URLSearchParams();
params.append('client_id', keycloakConfig.authClientId);
params.append('client_secret', keycloakConfig.authClientSecret);
params.append('token', token);
try {
const response = await firstValueFrom(
this.httpService.post(introspectEndpoint, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
);
return response.data.active === true;
} catch (error: any) {
this.logger.error('Online token validation failed', error.response?.data);
return false;
}
}
async getStoredRefreshToken(accessToken: string): Promise<string | null> {
// Implémentez votre logique de stockage des refresh tokens ici
// Pour l'instant, retournez null ou implémentez selon vos besoins
return null;
}
// === MÉTHODES UTILITAIRES ===
clearToken(): void {
this.currentToken = null;
this.tokenExpiry = null;
this.logger.log('Admin client token cleared from cache');
}
getTokenInfo(): { hasToken: boolean; expiresIn?: number; clientType: string } {
if (!this.currentToken || !this.tokenExpiry) {
return { hasToken: false, clientType: 'admin' };
}
const expiresIn = this.tokenExpiry.getTime() - Date.now();
const expiresIn = Math.max(0, Math.floor((this.serviceTokenExpiry - Date.now()) / 1000));
return {
hasToken: true,
expiresIn: Math.max(0, Math.floor(expiresIn / 1000)), // en secondes
clientType: 'admin'
expiresIn: expiresIn > 0 ? expiresIn : undefined,
};
}
}

View File

@ -6,8 +6,8 @@ export interface KeycloakConfig {
realm: string;
publicKey?: string;
// Client pour l'API Admin (Service Account - client_credentials)
adminClientId: string;
adminClientSecret: string;
//adminClientId: string;
//adminClientSecret: string;
// Client pour l'authentification utilisateur (Password Grant)
authClientId: string;
authClientSecret: string;
@ -20,11 +20,11 @@ export default registerAs('keycloak', (): KeycloakConfig => ({
realm: process.env.KEYCLOAK_REALM || 'dcb-dev',
publicKey: process.env.KEYCLOAK_PUBLIC_KEY,
// Client pour Service Account (API Admin)
adminClientId: process.env.KEYCLOAK_ADMIN_CLIENT_ID || 'dcb-user-service-cc',
adminClientSecret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET || '',
//adminClientId: process.env.KEYCLOAK_ADMIN_CLIENT_ID || 'dcb-user-service-cc',
//adminClientSecret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET || '',
// Client pour Password Grant (Authentification utilisateur)
authClientId: process.env.KEYCLOAK_AUTH_CLIENT_ID || 'dcb-user-service-pwd',
authClientSecret: process.env.KEYCLOAK_AUTH_CLIENT_SECRET || '',
authClientId: process.env.KEYCLOAK_CLIENT_ID || 'dcb-user-service-pwd',
authClientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '',
validationMode: process.env.KEYCLOAK_VALIDATION_MODE || 'online',
tokenBufferSeconds: Number(process.env.KEYCLOAK_TOKEN_BUFFER_SECONDS) || 30,
}));
@ -53,7 +53,7 @@ export const keycloakConfigValidationSchema = Joi.object({
'any.required': 'KEYCLOAK_PUBLIC_KEY is required'
}),
KEYCLOAK_ADMIN_CLIENT_ID: Joi.string()
/*KEYCLOAK_ADMIN_CLIENT_ID: Joi.string()
.required()
.messages({
'any.required': 'KEYCLOAK_ADMIN_CLIENT_ID is required'
@ -65,20 +65,20 @@ export const keycloakConfigValidationSchema = Joi.object({
.messages({
'any.required': 'KEYCLOAK_ADMIN_CLIENT_SECRET is required',
'string.min': 'KEYCLOAK_ADMIN_CLIENT_SECRET cannot be empty'
}),
}),*/
KEYCLOAK_AUTH_CLIENT_ID: Joi.string()
KEYCLOAK_CLIENT_ID: Joi.string()
.required()
.messages({
'any.required': 'KEYCLOAK_AUTH_CLIENT_ID is required'
'any.required': 'KEYCLOAK_CLIENT_ID is required'
}),
KEYCLOAK_AUTH_CLIENT_SECRET: Joi.string()
KEYCLOAK_CLIENT_SECRET: Joi.string()
.required()
.min(1)
.messages({
'any.required': 'KEYCLOAK_AUTH_CLIENT_SECRET is required',
'string.min': 'KEYCLOAK_AUTH_CLIENT_SECRET cannot be empty'
'any.required': 'KEYCLOAK_CLIENT_SECRET is required',
'string.min': 'KEYCLOAK_CLIENT_SECRET cannot be empty'
}),
KEYCLOAK_VALIDATION_MODE: Joi.string()

View File

@ -0,0 +1,3 @@
export const RESOURCES = {
USER: 'user',// user resource for /users/* endpoints
};

5
src/constants/scopes.ts Normal file
View File

@ -0,0 +1,5 @@
export const SCOPES = {
READ: 'read',
WRITE: 'write',
DELETE: 'delete',
};

View File

@ -1,8 +0,0 @@
import { SetMetadata } from '@nestjs/common';
/**
* Décorateur custom pour définir les rôles requis sur une route.
* Exemple :
* @Roles('DCB_ADMIN', 'DCB_MANAGER')
*/
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

View File

@ -10,201 +10,240 @@ import {
Logger,
HttpException,
HttpStatus,
UseGuards,
} from "@nestjs/common";
import {
Roles,
AuthenticatedUser,
Public
} from "nest-keycloak-connect";
import { Roles, AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect";
import { UsersService } from "../services/users.service";
import { User, CreateUserDto, UpdateUserDto, UserResponse } from "../models/user";
import { ROLES } from "../models/roles.enum";
import * as user from "../models/user";
import { RESOURCES } from '../../constants/resouces';
import { SCOPES } from '../../constants/scopes';
@Controller("user")
export class UserController {
private readonly logger = new Logger(UserController.name);
@Controller('users')
@Resource(RESOURCES.USER)
export class UsersController {
private readonly logger = new Logger(UsersController.name);
constructor(private readonly userService: UsersService) {}
@Post("create")
@Roles({ roles: [ROLES.CREATE_USER] })
async createUser(
@Body() payload: CreateUserDto,
@AuthenticatedUser() user: any
): Promise<UserResponse> {
this.logger.log(`User ${user.sub} creating new user: ${payload.username}`);
constructor(private readonly usersService: UsersService) {}
// === CREATE USER ===
@Post()
@Scopes(SCOPES.WRITE)
async createUser(@Body() createUserDto: user.CreateUserDto): Promise<user.UserResponse> {
this.logger.log(`Creating new user: ${createUserDto.username}`);
try {
const createdUser = await this.userService.createUser(payload);
return new UserResponse(createdUser);
const createdUser = await this.usersService.createUser(createUserDto);
return createdUser;
} catch (error: any) {
this.logger.error(`Failed to create user: ${error.message}`);
throw new HttpException(
error.message || 'Failed to create user',
HttpStatus.BAD_REQUEST
);
throw new HttpException(error.message || "Failed to create user", HttpStatus.BAD_REQUEST);
}
}
// === GET ALL USERS ===
@Get()
@Scopes(SCOPES.READ)
async findAllUsers(@Query() query: user.UserQueryDto): Promise<user.PaginatedUserResponse> {
this.logger.log('Fetching users list');
try {
const result = await this.usersService.findAllUsers(query);
return result;
} catch (error: any) {
this.logger.error(`Failed to fetch users: ${error.message}`);
throw new HttpException(error.message || "Failed to fetch users", HttpStatus.INTERNAL_SERVER_ERROR);
}
}
// === GET USER BY ID ===
@Get(':id')
@Scopes(SCOPES.READ)
async getUserById(@Param('id') id: string): Promise<user.UserResponse> {
this.logger.log(`Fetching user by ID: ${id}`);
try {
const user = await this.usersService.getUserById(id);
return user;
} catch (error: any) {
this.logger.error(`Failed to fetch user ${id}: ${error.message}`);
throw new HttpException(error.message || "User not found", HttpStatus.NOT_FOUND);
}
}
// === GET CURRENT USER PROFILE ===
@Get('profile/me')
@Scopes(SCOPES.READ)
async getCurrentUserProfile(@AuthenticatedUser() user: any): Promise<user.UserResponse> {
this.logger.log(`User ${user.sub} accessing own profile`);
try {
const userProfile = await this.usersService.getUserProfile(user);
return userProfile;
} catch (error: any) {
this.logger.error(`Failed to fetch user profile: ${error.message}`);
throw new HttpException(error.message || "Failed to fetch user profile", HttpStatus.NOT_FOUND);
}
}
// === UPDATE USER ===
@Put(':id')
@Roles({ roles: [ROLES.UPDATE_USER] })
@Scopes(SCOPES.WRITE)
async updateUser(
@Param('id') id: string,
@Body() payload: UpdateUserDto,
@AuthenticatedUser() user: any
): Promise<UserResponse> {
this.logger.log(`User ${user.sub} updating user: ${id}`);
@Body() updateUserDto: user.UpdateUserDto
): Promise<user.UserResponse> {
this.logger.log(`Updating user: ${id}`);
try {
const updatedUser = await this.userService.updateUser(id, payload);
return new UserResponse(updatedUser);
const updatedUser = await this.usersService.updateUser(id, updateUserDto);
return updatedUser;
} catch (error: any) {
this.logger.error(`Failed to update user ${id}: ${error.message}`);
throw new HttpException(
error.message || 'Failed to update user',
HttpStatus.BAD_REQUEST
);
throw new HttpException(error.message || "Failed to update user", HttpStatus.BAD_REQUEST);
}
}
@Delete(":id")
@Roles({ roles: [ROLES.DELETE_USER] })
async deleteUser(
@Param("id") id: string,
@AuthenticatedUser() user: any
): Promise<{ message: string }> {
this.logger.log(`User ${user.sub} deleting user: ${id}`);
// === UPDATE CURRENT USER PROFILE ===
@Put('profile/me')
async updateCurrentUserProfile(
@AuthenticatedUser() user: any,
@Body() updateUserDto: user.UpdateUserDto
): Promise<user.UserResponse> {
this.logger.log(`User ${user.sub} updating own profile`);
try {
await this.userService.deleteUser(id);
// Un utilisateur ne peut mettre à jour que son propre profil
const updatedUser = await this.usersService.updateUser(user.sub, updateUserDto);
return updatedUser;
} catch (error: any) {
this.logger.error(`Failed to update user profile: ${error.message}`);
throw new HttpException(error.message || "Failed to update user profile", HttpStatus.BAD_REQUEST);
}
}
// === DELETE USER ===
@Delete(':id')
@Scopes(SCOPES.DELETE)
async deleteUser(@Param('id') id: string): Promise<{ message: string }> {
this.logger.log(`Deleting user: ${id}`);
try {
await this.usersService.deleteUser(id);
return { message: `User ${id} deleted successfully` };
} catch (error: any) {
this.logger.error(`Failed to delete user ${id}: ${error.message}`);
throw new HttpException(
error.message || 'Failed to delete user',
HttpStatus.BAD_REQUEST
);
throw new HttpException(error.message || "Failed to delete user", HttpStatus.BAD_REQUEST);
}
}
@Get()
@Roles({ roles: [ROLES.READ_USERS] })
async findAllUsers(
@Query('page') page: number = 1,
@Query('limit') limit: number = 10,
@Query('search') search: string = '',
@Query('enabled') enabled: boolean | string,
@AuthenticatedUser() user: any
): Promise<{ users: UserResponse[]; total: number }> {
this.logger.log(`User ${user.sub} accessing users list`);
// Convert enabled query param to boolean if provided
const enabledFilter = enabled === 'true' ? true :
enabled === 'false' ? false : undefined;
try {
const result = await this.userService.findAllUsers(
page,
limit,
search,
enabledFilter
);
return {
users: result.users.map(user => new UserResponse(user)),
total: result.total
};
} catch (error: any) {
this.logger.error(`Failed to fetch users: ${error.message}`);
throw new HttpException(
error.message || 'Failed to fetch users',
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
@Get(":id")
@Roles({ roles: [ROLES.READ_USERS] })
async getUserById(
@Param('id') id: string,
@AuthenticatedUser() user: any
): Promise<UserResponse> {
this.logger.log(`User ${user.sub} accessing profile of user: ${id}`);
try {
const userData = await this.userService.getUserById(id);
return new UserResponse(userData);
} catch (error: any) {
this.logger.error(`Failed to fetch user ${id}: ${error.message}`);
throw new HttpException(
error.message || 'User not found',
HttpStatus.NOT_FOUND
);
}
}
@Get("profile/me")
async getCurrentUserProfile(
@AuthenticatedUser() user: any
): Promise<UserResponse> {
this.logger.log(`User ${user.sub} accessing own profile`);
try {
// Utiliser l'ID de l'utilisateur authentifié
const userData = await this.userService.getUserById(user.sub);
return new UserResponse(userData);
} catch (error: any) {
this.logger.error(`Failed to fetch current user profile: ${error.message}`);
throw new HttpException(
error.message || 'Failed to fetch user profile',
HttpStatus.NOT_FOUND
);
}
}
@Post(":id/roles")
@Roles({ roles: [ROLES.UPDATE_USER] })
async assignRoles(
@Param('id') id: string,
@Body() body: { roles: string[] },
@AuthenticatedUser() user: any
): Promise<{ message: string }> {
this.logger.log(`User ${user.sub} assigning roles to user: ${id}`);
try {
await this.userService.assignRealmRoles(id, body.roles);
return { message: 'Roles assigned successfully' };
} catch (error: any) {
this.logger.error(`Failed to assign roles to user ${id}: ${error.message}`);
throw new HttpException(
error.message || 'Failed to assign roles',
HttpStatus.BAD_REQUEST
);
}
}
@Put(":id/password")
@Roles({ roles: [ROLES.UPDATE_USER] })
// === RESET PASSWORD ===
@Put(':id/password')
@Scopes(SCOPES.WRITE)
async resetPassword(
@Param('id') id: string,
@Body() body: { password: string; temporary: boolean },
@AuthenticatedUser() user: any
@Body() resetPasswordDto: user.ResetPasswordDto
): Promise<{ message: string }> {
this.logger.log(`User ${user.sub} resetting password for user: ${id}`);
this.logger.log(`Resetting password for user: ${id}`);
try {
await this.userService.resetPassword(
id,
body.password,
body.temporary ?? true
);
await this.usersService.resetPassword(resetPasswordDto);
return { message: 'Password reset successfully' };
} catch (error: any) {
this.logger.error(`Failed to reset password for user ${id}: ${error.message}`);
throw new HttpException(
error.message || 'Failed to reset password',
HttpStatus.BAD_REQUEST
);
throw new HttpException(error.message || "Failed to reset password", HttpStatus.BAD_REQUEST);
}
}
// === ENABLE USER ===
@Put(':id/enable')
@Scopes(SCOPES.WRITE)
async enableUser(@Param('id') id: string): Promise<{ message: string }> {
this.logger.log(`Enabling user: ${id}`);
try {
await this.usersService.enableUser(id);
return { message: 'User enabled successfully' };
} catch (error: any) {
this.logger.error(`Failed to enable user ${id}: ${error.message}`);
throw new HttpException(error.message || "Failed to enable user", HttpStatus.BAD_REQUEST);
}
}
// === DISABLE USER ===
@Put(':id/disable')
@Scopes(SCOPES.WRITE)
async disableUser(@Param('id') id: string): Promise<{ message: string }> {
this.logger.log(`Disabling user: ${id}`);
try {
await this.usersService.disableUser(id);
return { message: 'User disabled successfully' };
} catch (error: any) {
this.logger.error(`Failed to disable user ${id}: ${error.message}`);
throw new HttpException(error.message || "Failed to disable user", HttpStatus.BAD_REQUEST);
}
}
// === CHECK USER EXISTS ===
@Get('check/:username')
@Scopes(SCOPES.READ)
async userExists(@Param('username') username: string): Promise<{ exists: boolean }> {
this.logger.log(`Checking if user exists: ${username}`);
try {
const exists = await this.usersService.userExists(username);
return { exists };
} catch (error: any) {
this.logger.error(`Failed to check if user exists ${username}: ${error.message}`);
throw new HttpException(error.message || "Failed to check user existence", HttpStatus.BAD_REQUEST);
}
}
// === SEARCH USERS BY USERNAME ===
@Get('search/username/:username')
@Scopes(SCOPES.READ)
async findUserByUsername(@Param('username') username: string): Promise<user.UserResponse[]> {
this.logger.log(`Searching users by username: ${username}`);
try {
const users = await this.usersService.findUserByUsername(username);
return users;
} catch (error: any) {
this.logger.error(`Failed to search users by username ${username}: ${error.message}`);
throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST);
}
}
// === SEARCH USERS BY EMAIL ===
@Get('search/email/:email')
@Scopes(SCOPES.READ)
async findUserByEmail(@Param('email') email: string): Promise<user.UserResponse[]> {
this.logger.log(`Searching users by email: ${email}`);
try {
const users = await this.usersService.findUserByEmail(email);
return users;
} catch (error: any) {
this.logger.error(`Failed to search users by email ${email}: ${error.message}`);
throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST);
}
}
// === GET USER ROLES ===
@Get(':id/roles')
@Scopes(SCOPES.READ)
async getUserClientRoles(@Param('id') id: string): Promise<{ roles: string[] }> {
this.logger.log(`Fetching roles for user: ${id}`);
try {
const roles = await this.usersService.getUserClientRoles(id);
return { roles };
} catch (error: any) {
this.logger.error(`Failed to fetch roles for user ${id}: ${error.message}`);
throw new HttpException(error.message || "Failed to fetch user roles", HttpStatus.BAD_REQUEST);
}
}
// === ASSIGN CLIENT ROLES TO USER ===
@Put(':id/roles')
@Scopes(SCOPES.WRITE)
async assignClientRoles(
@Param('id') id: string,
@Body() assignRolesDto: { roles: string[] }
): Promise<{ message: string }> {
this.logger.log(`Assigning roles to user: ${id}`, assignRolesDto.roles);
try {
await this.usersService.assignClientRoles(id, assignRolesDto.roles);
return { message: 'Roles assigned successfully' };
} catch (error: any) {
this.logger.error(`Failed to assign roles to user ${id}: ${error.message}`);
throw new HttpException(error.message || "Failed to assign roles", HttpStatus.BAD_REQUEST);
}
}
}

View File

@ -1,17 +1,15 @@
import { IsString, IsEmail, IsBoolean, IsOptional, IsArray, MinLength } from 'class-validator';
export class User {
id?: string;
username: string;
email: string;
firstName?: string;
lastName?: string;
firstName: string;
lastName: string;
enabled?: boolean = true;
emailVerified?: boolean = false;
attributes?: Record<string, any>;
realmRoles?: string[];
clientRoles?: Record<string, string[]>;
groups?: string[];
requiredActions?: string[];
credentials?: UserCredentials[];
clientRoles?: string[]; // Rôles client uniquement (admin, merchant, support)
createdTimestamp?: number;
constructor(partial?: Partial<User>) {
@ -34,40 +32,103 @@ export class UserCredentials {
}
export class CreateUserDto {
@IsString()
@MinLength(3)
username: string;
email: string;
firstName?: string;
lastName?: string;
password: string;
enabled?: boolean = true;
emailVerified?: boolean = false;
attributes?: Record<string, any>;
realmRoles?: string[];
groups?: string[];
constructor(partial?: Partial<CreateUserDto>) {
if (partial) {
Object.assign(this, partial);
}
}
@IsEmail()
email: string;
@IsString()
firstName: string;
@IsString()
lastName: string;
@IsString()
@MinLength(8)
password: string;
@IsOptional()
@IsBoolean()
enabled?: boolean = true;
@IsOptional()
@IsBoolean()
emailVerified?: boolean = false;
@IsOptional()
attributes?: Record<string, any>;
@IsOptional()
@IsArray()
clientRoles?: string[];
}
export class UpdateUserDto {
@IsOptional()
@IsString()
username?: string;
email?: string;
firstName?: string;
lastName?: string;
enabled?: boolean;
emailVerified?: boolean;
attributes?: Record<string, any>;
realmRoles?: string[];
groups?: string[];
constructor(partial?: Partial<UpdateUserDto>) {
if (partial) {
Object.assign(this, partial);
}
}
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
firstName?: string;
@IsOptional()
@IsString()
lastName?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsBoolean()
emailVerified?: boolean;
@IsOptional()
attributes?: Record<string, any>;
@IsOptional()
@IsArray()
clientRoles?: string[];
}
export class UserQueryDto {
@IsOptional()
page?: number = 1;
@IsOptional()
limit?: number = 10;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsBoolean()
emailVerified?: boolean;
}
export class ResetPasswordDto {
@IsString()
userId: string;
@IsString()
@MinLength(8)
newPassword: string;
@IsOptional()
@IsBoolean()
temporary?: boolean = false;
}
export class UserResponse {
@ -79,9 +140,7 @@ export class UserResponse {
enabled: boolean;
emailVerified: boolean;
attributes?: Record<string, any>;
realmRoles?: string[];
clientRoles?: Record<string, string[]>;
groups?: string[];
clientRoles: string[]; // Rôles client uniquement
createdTimestamp: number;
constructor(user: any) {
@ -93,9 +152,47 @@ export class UserResponse {
this.enabled = user.enabled;
this.emailVerified = user.emailVerified;
this.attributes = user.attributes;
this.realmRoles = user.realmRoles;
this.clientRoles = user.clientRoles;
this.groups = user.groups;
this.clientRoles = user.clientRoles || [];
this.createdTimestamp = user.createdTimestamp;
}
}
// Interface pour les réponses paginées
export class PaginatedUserResponse {
users: UserResponse[];
total: number;
page: number;
limit: number;
totalPages: number;
constructor(users: UserResponse[], total: number, page: number, limit: number) {
this.users = users;
this.total = total;
this.page = page;
this.limit = limit;
this.totalPages = Math.ceil(total / limit);
}
}
export class AssignRolesDto {
@IsArray()
@IsString({ each: true })
roles: string[];
}
// Types pour les rôles client
export type ClientRole = 'admin' | 'merchant' | 'support';
// Interface pour l'authentification
export interface LoginDto {
username: string;
password: string;
}
export interface TokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
scope?: string;
}

View File

@ -1,311 +1,358 @@
import { Injectable, Logger, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
import { ConfigService } from '@nestjs/config';
import { CreateUserDto, UpdateUserDto } from '../models/user';
import {
CreateUserDto,
UpdateUserDto,
UserResponse,
PaginatedUserResponse,
ResetPasswordDto,
UserQueryDto,
LoginDto,
TokenResponse,
ClientRole
} from '../models/user';
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
private readonly realm: string;
constructor(
private readonly keycloakApi: KeycloakApiService,
private readonly configService: ConfigService,
) {
this.realm = this.configService.get<string>('keycloak.realm')!;
) {}
// === VALIDATION DES ROLES ===
private validateClientRole(role: string): ClientRole {
const validRoles: ClientRole[] = ['admin', 'merchant', 'support'];
if (!validRoles.includes(role as ClientRole)) {
throw new BadRequestException(`Invalid client role: ${role}. Valid roles are: ${validRoles.join(', ')}`);
}
return role as ClientRole;
}
async getUserById(userId: string): Promise<any> {
private validateClientRoles(roles: string[]): ClientRole[] {
return roles.map(role => this.validateClientRole(role));
}
// === AUTHENTIFICATION UTILISATEUR ===
async authenticateUser(loginDto: LoginDto): Promise<TokenResponse> {
return this.keycloakApi.authenticateUser(loginDto.username, loginDto.password);
}
// === GET USER BY ID ===
async getUserById(userId: string): Promise<UserResponse> {
try {
this.logger.debug(`Fetching user by ID: ${userId}`);
const user = await this.keycloakApi.getUserById(this.realm, userId);
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found`);
}
const user = await this.keycloakApi.getUserById(userId);
if (!user) throw new NotFoundException(`User with ID ${userId} not found`);
return user;
const roles = await this.keycloakApi.getUserClientRoles(userId);
const clientRoles = roles.map(role => role.name);
return new UserResponse({ ...user, clientRoles });
} catch (error: any) {
this.logger.error(`Failed to fetch user ${userId}: ${error.message}`);
if (error instanceof NotFoundException) {
throw error;
}
if (error instanceof NotFoundException) throw error;
throw new NotFoundException(`User with ID ${userId} not found`);
}
}
async getUserProfile(accessToken: string): Promise<any> {
// === GET PROFILE FROM DECODED TOKEN ===
async getUserProfile(decodedToken: any): Promise<UserResponse> {
try {
this.logger.debug('Fetching user profile from token');
const profile = await this.keycloakApi.getUserProfile(this.realm, accessToken);
const profileData = {
id: decodedToken.sub,
username: decodedToken.preferred_username || decodedToken.username,
email: decodedToken.email,
firstName: decodedToken.given_name || decodedToken.firstName,
lastName: decodedToken.family_name || decodedToken.lastName,
enabled: true,
emailVerified: decodedToken.email_verified || false,
attributes: decodedToken.attributes || {},
clientRoles: decodedToken.resource_access ?
Object.values(decodedToken.resource_access).flatMap((client: any) => client.roles || []) : [],
realmRoles: decodedToken.realm_access?.roles || [],
createdTimestamp: decodedToken.iat ? decodedToken.iam * 1000 : Date.now()
};
if (!profile) {
throw new NotFoundException('User profile not found');
}
return profile;
return new UserResponse(profileData);
} catch (error: any) {
this.logger.error(`Failed to fetch user profile: ${error.message}`);
throw new NotFoundException('Failed to fetch user profile');
this.logger.error(`Failed to create user profile from token: ${error.message}`);
throw new NotFoundException('Failed to create user profile');
}
}
async findAllUsers(
page: number = 1,
limit: number = 10,
search: string = '',
enabled?: boolean
): Promise<{ users: any[]; total: number }> {
// === FIND ALL USERS ===
async findAllUsers(query: UserQueryDto): Promise<PaginatedUserResponse> {
try {
this.logger.debug(`Fetching users - page: ${page}, limit: ${limit}, search: ${search}`);
let users = await this.keycloakApi.getAllUsers();
// Récupérer tous les utilisateurs avec les filtres
let users = await this.keycloakApi.getUsers(this.realm);
// Appliquer les filtres
if (search) {
const searchLower = search.toLowerCase();
users = users.filter(user =>
user.username?.toLowerCase().includes(searchLower) ||
user.email?.toLowerCase().includes(searchLower) ||
user.firstName?.toLowerCase().includes(searchLower) ||
user.lastName?.toLowerCase().includes(searchLower)
// Filtre de recherche
if (query.search) {
const q = query.search.toLowerCase();
users = users.filter(
(u) =>
u.username?.toLowerCase().includes(q) ||
u.email?.toLowerCase().includes(q) ||
u.firstName?.toLowerCase().includes(q) ||
u.lastName?.toLowerCase().includes(q),
);
}
if (enabled !== undefined) {
users = users.filter(user => user.enabled === enabled);
// Filtre par statut enabled
if (query.enabled !== undefined) {
users = users.filter((u) => u.enabled === query.enabled);
}
// Pagination
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedUsers = users.slice(startIndex, endIndex);
if (query.emailVerified !== undefined) {
users = users.filter((u) => u.emailVerified === query.emailVerified);
}
return {
users: paginatedUsers,
total: users.length
};
const page = query.page || 1;
const limit = query.limit || 10;
const startIndex = (page - 1) * limit;
const paginatedUsers = users.slice(startIndex, startIndex + limit);
const usersWithRoles = await Promise.all(
paginatedUsers.map(async (u) => {
try {
const roles = await this.keycloakApi.getUserClientRoles(u.id!);
const clientRoles = roles.map(role => role.name);
return new UserResponse({ ...u, clientRoles });
} catch (error) {
this.logger.warn(`Failed to fetch roles for user ${u.id}: ${error.message}`);
return new UserResponse({ ...u, clientRoles: [] });
}
}),
);
return new PaginatedUserResponse(usersWithRoles, users.length, page, limit);
} catch (error: any) {
this.logger.error(`Failed to fetch users: ${error.message}`);
throw new BadRequestException('Failed to fetch users');
}
}
async createUser(userData: CreateUserDto): Promise<any> {
// === CREATE USER ===
async createUser(userData: CreateUserDto): Promise<UserResponse> {
try {
this.logger.debug(`Creating new user: ${userData.username}`);
// Validation basique
if (!userData.username || !userData.email) {
throw new BadRequestException('Username and email are required');
}
// Vérifier si l'utilisateur existe déjà
const existingUsers = await this.keycloakApi.getUsers(this.realm);
const userExists = existingUsers.some(user =>
user.username === userData.username || user.email === userData.email
);
const existingByUsername = await this.keycloakApi.findUserByUsername(userData.username);
if (userExists) {
throw new ConflictException('User with this username or email already exists');
if (userData.email) {
const existingByEmail = await this.keycloakApi.findUserByEmail(userData.email);
if (existingByEmail.length > 0) {
throw new ConflictException('User with this email already exists');
}
}
// Préparer les données pour Keycloak
const keycloakUserData = {
if (existingByUsername.length > 0) {
throw new ConflictException('User with this username already exists');
}
const userId = await this.keycloakApi.createUser({
username: userData.username,
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
password: userData.password,
enabled: userData.enabled ?? true,
emailVerified: userData.emailVerified ?? false,
attributes: userData.attributes || {},
credentials: userData.password ? [
{
type: 'password',
value: userData.password,
temporary: false
});
// Attribution automatique de rôles client si fournis
if (userData.clientRoles?.length) {
const validatedRoles = this.validateClientRoles(userData.clientRoles);
await this.keycloakApi.setClientRoles(userId, validatedRoles);
}
] : [],
realmRoles: userData.realmRoles || [],
groups: userData.groups || []
};
const createdUser = await this.keycloakApi.createUser(this.realm, keycloakUserData);
this.logger.log(`User created successfully: ${userData.username}`);
const createdUser = await this.keycloakApi.getUserById(userId);
const roles = await this.keycloakApi.getUserClientRoles(userId);
const clientRoles = roles.map(role => role.name);
return createdUser;
return new UserResponse({ ...createdUser, clientRoles });
} catch (error: any) {
this.logger.error(`Failed to create user ${userData.username}: ${error.message}`);
if (error instanceof BadRequestException || error instanceof ConflictException) {
throw error;
}
this.logger.error(`Failed to create user: ${error.message}`);
if (error instanceof ConflictException || error instanceof BadRequestException) throw error;
throw new BadRequestException('Failed to create user');
}
}
async updateUser(id: string, userData: UpdateUserDto): Promise<any> {
// === UPDATE USER ===
async updateUser(id: string, userData: UpdateUserDto): Promise<UserResponse> {
try {
this.logger.debug(`Updating user: ${id}`);
// Vérifier que l'utilisateur existe
const existingUser = await this.getUserById(id);
await this.keycloakApi.getUserById(id);
if (!existingUser) {
throw new NotFoundException(`User with ID ${id} not found`);
await this.keycloakApi.updateUser(id, userData);
// Mettre à jour les rôles si fournis
if (userData.clientRoles) {
const validatedRoles = this.validateClientRoles(userData.clientRoles);
await this.keycloakApi.setClientRoles(id, validatedRoles);
}
// Préparer les données de mise à jour
const updateData: any = {};
const updatedUser = await this.keycloakApi.getUserById(id);
const roles = await this.keycloakApi.getUserClientRoles(id);
const clientRoles = roles.map(role => role.name);
if (userData.username !== undefined) updateData.username = userData.username;
if (userData.email !== undefined) updateData.email = userData.email;
if (userData.firstName !== undefined) updateData.firstName = userData.firstName;
if (userData.lastName !== undefined) updateData.lastName = userData.lastName;
if (userData.enabled !== undefined) updateData.enabled = userData.enabled;
if (userData.emailVerified !== undefined) updateData.emailVerified = userData.emailVerified;
if (userData.attributes !== undefined) updateData.attributes = userData.attributes;
if (userData.realmRoles !== undefined) updateData.realmRoles = userData.realmRoles;
if (userData.groups !== undefined) updateData.groups = userData.groups;
const updatedUser = await this.keycloakApi.updateUser(this.realm, id, updateData);
this.logger.log(`User updated successfully: ${id}`);
return updatedUser;
return new UserResponse({ ...updatedUser, clientRoles });
} catch (error: any) {
this.logger.error(`Failed to update user ${id}: ${error.message}`);
if (error instanceof NotFoundException) {
throw error;
}
if (error instanceof NotFoundException || error instanceof BadRequestException) throw error;
throw new BadRequestException('Failed to update user');
}
}
async deleteUser(id: string): Promise<void> {
// === ASSIGN CLIENT ROLES TO USER ===
async assignClientRoles(userId: string, roles: string[]): Promise<{ message: string }> {
try {
this.logger.debug(`Deleting user: ${id}`);
this.logger.log(`Assigning client roles to user: ${userId}`, roles);
// Vérifier que l'utilisateur existe
const existingUser = await this.getUserById(id);
await this.keycloakApi.getUserById(userId);
if (!existingUser) {
throw new NotFoundException(`User with ID ${id} not found`);
}
// Valider et assigner les rôles
const validatedRoles = this.validateClientRoles(roles);
await this.keycloakApi.setClientRoles(userId, validatedRoles);
await this.keycloakApi.deleteUser(this.realm, id);
this.logger.log(`User deleted successfully: ${id}`);
} catch (error: any) {
this.logger.error(`Failed to delete user ${id}: ${error.message}`);
if (error instanceof NotFoundException) {
throw error;
}
throw new BadRequestException('Failed to delete user');
}
}
async assignRealmRoles(userId: string, roles: string[]): Promise<void> {
try {
this.logger.debug(`Assigning roles to user ${userId}: ${roles.join(', ')}`);
// Vérifier que l'utilisateur existe
await this.getUserById(userId);
await this.keycloakApi.assignRealmRoles(this.realm, userId, roles);
this.logger.log(`Roles assigned successfully to user: ${userId}`);
this.logger.log(`Successfully assigned ${validatedRoles.length} roles to user ${userId}`);
return { message: 'Roles assigned successfully' };
} catch (error: any) {
this.logger.error(`Failed to assign roles to user ${userId}: ${error.message}`);
if (error instanceof NotFoundException || error instanceof BadRequestException) throw error;
throw new BadRequestException('Failed to assign roles to user');
}
}
async removeRealmRoles(userId: string, roles: string[]): Promise<void> {
// === DELETE USER ===
async deleteUser(id: string): Promise<void> {
try {
this.logger.debug(`Removing roles from user ${userId}: ${roles.join(', ')}`);
// Vérifier que l'utilisateur existe
await this.getUserById(userId);
await this.keycloakApi.removeRealmRoles(this.realm, userId, roles);
this.logger.log(`Roles removed successfully from user: ${userId}`);
await this.keycloakApi.getUserById(id);
await this.keycloakApi.deleteUser(id);
} catch (error: any) {
this.logger.error(`Failed to remove roles from user ${userId}: ${error.message}`);
throw new BadRequestException('Failed to remove roles from user');
this.logger.error(`Failed to delete user ${id}: ${error.message}`);
if (error instanceof NotFoundException) throw error;
throw new BadRequestException('Failed to delete user');
}
}
async resetPassword(userId: string, newPassword: string, temporary: boolean = true): Promise<void> {
// === RESET PASSWORD ===
async resetPassword(resetPasswordDto: ResetPasswordDto): Promise<void> {
try {
this.logger.debug(`Resetting password for user: ${userId}`);
// Vérifier que l'utilisateur existe
await this.getUserById(userId);
await this.keycloakApi.resetPassword(this.realm, userId, newPassword, temporary);
this.logger.log(`Password reset successfully for user: ${userId}`);
await this.keycloakApi.getUserById(resetPasswordDto.userId);
await this.keycloakApi.resetPassword(resetPasswordDto.userId, resetPasswordDto.newPassword);
} catch (error: any) {
this.logger.error(`Failed to reset password for user ${userId}: ${error.message}`);
this.logger.error(`Failed to reset password for user ${resetPasswordDto.userId}: ${error.message}`);
if (error instanceof NotFoundException) throw error;
throw new BadRequestException('Failed to reset password');
}
}
async searchUsers(query: string, maxResults: number = 50): Promise<any[]> {
// === ENABLE/DISABLE USER ===
async enableUser(userId: string): Promise<void> {
try {
this.logger.debug(`Searching users with query: ${query}`);
await this.keycloakApi.enableUser(userId);
} catch (error: any) {
this.logger.error(`Failed to enable user ${userId}: ${error.message}`);
throw new BadRequestException('Failed to enable user');
}
}
const allUsers = await this.keycloakApi.getUsers(this.realm);
async disableUser(userId: string): Promise<void> {
try {
await this.keycloakApi.disableUser(userId);
} catch (error: any) {
this.logger.error(`Failed to disable user ${userId}: ${error.message}`);
throw new BadRequestException('Failed to disable user');
}
}
const searchLower = query.toLowerCase();
const filteredUsers = allUsers.filter(user =>
user.username?.toLowerCase().includes(searchLower) ||
user.email?.toLowerCase().includes(searchLower) ||
user.firstName?.toLowerCase().includes(searchLower) ||
user.lastName?.toLowerCase().includes(searchLower) ||
user.id?.toLowerCase().includes(searchLower)
// === UTILITY METHODS ===
async userExists(username: string): Promise<boolean> {
try {
const users = await this.keycloakApi.findUserByUsername(username);
return users.length > 0;
} catch {
return false;
}
}
async getUserClientRoles(userId: string): Promise<string[]> {
try {
const roles = await this.keycloakApi.getUserClientRoles(userId);
return roles.map(role => role.name);
} catch (error: any) {
this.logger.error(`Failed to get client roles for user ${userId}: ${error.message}`);
throw new BadRequestException('Failed to get user client roles');
}
}
async findUserByUsername(username: string): Promise<UserResponse[]> {
try {
const users = await this.keycloakApi.findUserByUsername(username);
const usersWithRoles = await Promise.all(
users.map(async (user) => {
try {
const roles = await this.keycloakApi.getUserClientRoles(user.id!);
const clientRoles = roles.map(role => role.name);
return new UserResponse({ ...user, clientRoles });
} catch (error) {
this.logger.warn(`Failed to fetch roles for user ${user.id}: ${error.message}`);
return new UserResponse({ ...user, clientRoles: [] });
}
})
);
return filteredUsers.slice(0, maxResults);
return usersWithRoles;
} catch (error: any) {
this.logger.error(`Failed to search users: ${error.message}`);
throw new BadRequestException('Failed to search users');
this.logger.error(`Failed to find user by username ${username}: ${error.message}`);
throw new BadRequestException('Failed to find user by username');
}
}
async getUserCount(): Promise<{ total: number; enabled: number; disabled: number }> {
async findUserByEmail(email: string): Promise<UserResponse[]> {
try {
this.logger.debug('Getting user count statistics');
const users = await this.keycloakApi.findUserByEmail(email);
const allUsers = await this.keycloakApi.getUsers(this.realm);
return {
total: allUsers.length,
enabled: allUsers.filter(user => user.enabled).length,
disabled: allUsers.filter(user => !user.enabled).length
};
} catch (error: any) {
this.logger.error(`Failed to get user count: ${error.message}`);
throw new BadRequestException('Failed to get user statistics');
}
}
async toggleUserStatus(userId: string, enabled: boolean): Promise<any> {
const usersWithRoles = await Promise.all(
users.map(async (user) => {
try {
this.logger.debug(`Setting user ${userId} enabled status to: ${enabled}`);
const roles = await this.keycloakApi.getUserClientRoles(user.id!);
const clientRoles = roles.map(role => role.name);
return new UserResponse({ ...user, clientRoles });
} catch (error) {
this.logger.warn(`Failed to fetch roles for user ${user.id}: ${error.message}`);
return new UserResponse({ ...user, clientRoles: [] });
}
})
);
const user = await this.getUserById(userId);
return await this.updateUser(userId, { enabled });
return usersWithRoles;
} catch (error: any) {
this.logger.error(`Failed to toggle user status for ${userId}: ${error.message}`);
throw new BadRequestException('Failed to update user status');
this.logger.error(`Failed to find user by email ${email}: ${error.message}`);
throw new BadRequestException('Failed to find user by email');
}
}
// === PRIVATE METHODS ===
private decodeJwtToken(token: string): any {
try {
const payload = token.split('.')[1];
const decoded = JSON.parse(Buffer.from(payload, 'base64').toString());
return decoded;
} catch (error) {
this.logger.error('Failed to decode JWT token', error);
throw new BadRequestException('Invalid token format');
}
}
}

View File

@ -2,9 +2,8 @@ import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import { HttpModule } from '@nestjs/axios';
import { TokenService } from '../auth/services/token.service'
import { ClientCredentialsGuard } from '../auth/guards/client-credentials.guard';
import { UsersService } from './services/users.service'
import { UserController } from './controllers/users.controller'
import { UsersController } from './controllers/users.controller'
import { KeycloakApiService } from '../auth/services/keycloak-api.service';
@ -14,7 +13,7 @@ import { KeycloakApiService } from '../auth/services/keycloak-api.service';
JwtModule.register({}),
],
providers: [UsersService, KeycloakApiService, TokenService],
controllers: [UserController],
controllers: [UsersController],
exports: [UsersService, KeycloakApiService, TokenService, JwtModule],
})
export class UsersModule {}

View File

@ -1,40 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TokenService } from '../src/auth/services/token.service';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { of } from 'rxjs';
describe('TokenService', () => {
let service: TokenService;
const mockHttp = { post: jest.fn() };
const mockConfig = {
get: (key: string) => {
const map = {
'keycloak.serverUrl': 'https://keycloak-dcb.app.cameleonapp.com',
'keycloak.realm': 'master',
'keycloak.clientId': 'dcb-cc',
'keycloak.clientSecret': 'secret',
'keycloak.tokenBufferSeconds': 30,
};
return map[key];
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TokenService,
{ provide: HttpService, useValue: mockHttp },
{ provide: ConfigService, useValue: mockConfig },
],
}).compile();
service = module.get<TokenService>(TokenService);
});
it('should acquire a token', async () => {
mockHttp.post.mockReturnValue(of({ data: { access_token: 'abc123', expires_in: 60 } }));
const token = await service.acquireToken();
expect(token).toBe('abc123');
});
});