From 874ed845e5e6d0b70652ad49fc8ee1a6e2dd70ab Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Wed, 1 Mar 2023 15:54:02 +0100 Subject: [PATCH 01/34] chore: update dependencies --- package-lock.json | 181 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 5 ++ 2 files changed, 186 insertions(+) diff --git a/package-lock.json b/package-lock.json index fa7bd7cc..9683b56c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,11 @@ "@js-soft/docdb-access-mongo": "1.1.3", "@js-soft/node-logger": "1.0.3", "@js-soft/ts-utils": "^2.3.1", + "@nmshd/content": "*", "@nmshd/runtime": "2.3.6", "agentkeepalive": "4.2.1", "amqplib": "^0.10.3", + "async-retry": "^1.3.3", "axios": "^1.3.0", "compression": "1.7.4", "cors": "2.8.5", @@ -29,6 +31,7 @@ "multer": "^1.4.5-lts.1", "nconf": "0.12.0", "on-headers": "1.0.2", + "randexp": "^0.5.3", "rapidoc": "9.3.4", "reflect-metadata": "0.1.13", "swagger-ui-express": "4.6.0", @@ -41,7 +44,9 @@ "@js-soft/eslint-config-ts": "1.6.2", "@js-soft/license-check": "1.0.6", "@types/amqplib": "^0.10.1", + "@types/async-retry": "^1.4.4", "@types/compression": "^1.7.2", + "@types/config": "0.0.41", "@types/cors": "^2.8.13", "@types/express": "4.17.16", "@types/jest": "^29.4.0", @@ -2614,6 +2619,15 @@ "@types/node": "*" } }, + "node_modules/@types/async-retry": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@types/async-retry/-/async-retry-1.4.5.tgz", + "integrity": "sha512-YrdjSD+yQv7h6d5Ip+PMxh3H6ZxKyQk0Ts+PvaNRInxneG9PFVZjFg77ILAN+N6qYf7g4giSJ1l+ZjQ1zeegvA==", + "dev": true, + "dependencies": { + "@types/retry": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.0", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", @@ -2673,6 +2687,12 @@ "@types/express": "*" } }, + "node_modules/@types/config": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@types/config/-/config-0.0.41.tgz", + "integrity": "sha512-HjXUmIld0gwvyG8MU/17QtLzOyuMX4jbGuijmS9sWsob5xxgZ/hY9cbRCaHIHqTQ3HMLhwS3F8uXq3Bt9zgzHA==", + "dev": true + }, "node_modules/@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -2889,6 +2909,12 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, + "node_modules/@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true + }, "node_modules/@types/semver": { "version": "7.3.13", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", @@ -3372,6 +3398,14 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "dependencies": { + "retry": "0.13.1" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4334,6 +4368,14 @@ "node": ">=6.0.0" } }, + "node_modules/drange": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", + "engines": { + "node": ">=4" + } + }, "node_modules/easy-tsnameof": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/easy-tsnameof/-/easy-tsnameof-3.0.6.tgz", @@ -8651,6 +8693,18 @@ "integrity": "sha512-A9hihu7dUTLOUCM+I8E61V4kRXnN4DwYeK0DwCBydC1MqNI1PidyAtbtpsJlBBzK4icSctEcCQ1bGcLpBuETUQ==", "dev": true }, + "node_modules/randexp": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "dependencies": { + "drange": "^1.0.2", + "ret": "^0.2.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -8984,6 +9038,22 @@ "node": ">=10" } }, + "node_modules/ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -12540,9 +12610,12 @@ "@js-soft/ts-utils": "^2.3.1", "@nmshd/connector": "file:", "@nmshd/connector-sdk": "file:packages/sdk", + "@nmshd/content": "*", "@nmshd/runtime": "2.3.6", "@types/amqplib": "^0.10.1", + "@types/async-retry": "^1.4.4", "@types/compression": "^1.7.2", + "@types/config": "0.0.41", "@types/cors": "^2.8.13", "@types/express": "4.17.16", "@types/jest": "^29.4.0", @@ -12557,6 +12630,7 @@ "@types/yamljs": "^0.2.31", "agentkeepalive": "4.2.1", "amqplib": "^0.10.3", + "async-retry": "^1.3.3", "axios": "^1.3.0", "compression": "1.7.4", "cors": "2.8.5", @@ -12574,6 +12648,7 @@ "npm-run-all": "^4.1.5", "on-headers": "1.0.2", "prettier": "^2.8.3", + "randexp": "^0.5.3", "rapidoc": "9.3.4", "reflect-metadata": "0.1.13", "swagger-ui-express": "4.6.0", @@ -14698,6 +14773,15 @@ "@types/node": "*" } }, + "@types/async-retry": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@types/async-retry/-/async-retry-1.4.5.tgz", + "integrity": "sha512-YrdjSD+yQv7h6d5Ip+PMxh3H6ZxKyQk0Ts+PvaNRInxneG9PFVZjFg77ILAN+N6qYf7g4giSJ1l+ZjQ1zeegvA==", + "dev": true, + "requires": { + "@types/retry": "*" + } + }, "@types/babel__core": { "version": "7.20.0", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", @@ -14757,6 +14841,12 @@ "@types/express": "*" } }, + "@types/config": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@types/config/-/config-0.0.41.tgz", + "integrity": "sha512-HjXUmIld0gwvyG8MU/17QtLzOyuMX4jbGuijmS9sWsob5xxgZ/hY9cbRCaHIHqTQ3HMLhwS3F8uXq3Bt9zgzHA==", + "dev": true + }, "@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -14969,6 +15059,12 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, + "@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true + }, "@types/semver": { "version": "7.3.13", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", @@ -15301,6 +15397,14 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" }, + "async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "requires": { + "retry": "0.13.1" + } + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -16003,6 +16107,11 @@ "esutils": "^2.0.2" } }, + "drange": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==" + }, "easy-tsnameof": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/easy-tsnameof/-/easy-tsnameof-3.0.6.tgz", @@ -19196,6 +19305,15 @@ "integrity": "sha512-A9hihu7dUTLOUCM+I8E61V4kRXnN4DwYeK0DwCBydC1MqNI1PidyAtbtpsJlBBzK4icSctEcCQ1bGcLpBuETUQ==", "dev": true }, + "randexp": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "requires": { + "drange": "^1.0.2", + "ret": "^0.2.0" + } + }, "range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -19457,6 +19575,16 @@ "integrity": "sha512-6K/gDlqgQscOlg9fSRpWstA8sYe8rbELsSTNpx+3kTrsVCzvSl0zIvRErM7fdl9ERWDsKnrLnwB+Ne89918XOg==", "dev": true }, + "ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==" + }, + "retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==" + }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -20833,6 +20961,15 @@ "@types/node": "*" } }, + "@types/async-retry": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@types/async-retry/-/async-retry-1.4.5.tgz", + "integrity": "sha512-YrdjSD+yQv7h6d5Ip+PMxh3H6ZxKyQk0Ts+PvaNRInxneG9PFVZjFg77ILAN+N6qYf7g4giSJ1l+ZjQ1zeegvA==", + "dev": true, + "requires": { + "@types/retry": "*" + } + }, "@types/babel__core": { "version": "7.20.0", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", @@ -20892,6 +21029,12 @@ "@types/express": "*" } }, + "@types/config": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@types/config/-/config-0.0.41.tgz", + "integrity": "sha512-HjXUmIld0gwvyG8MU/17QtLzOyuMX4jbGuijmS9sWsob5xxgZ/hY9cbRCaHIHqTQ3HMLhwS3F8uXq3Bt9zgzHA==", + "dev": true + }, "@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -21104,6 +21247,12 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, + "@types/retry": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "dev": true + }, "@types/semver": { "version": "7.3.13", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", @@ -21436,6 +21585,14 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" }, + "async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "requires": { + "retry": "0.13.1" + } + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -22138,6 +22295,11 @@ "esutils": "^2.0.2" } }, + "drange": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==" + }, "easy-tsnameof": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/easy-tsnameof/-/easy-tsnameof-3.0.6.tgz", @@ -25331,6 +25493,15 @@ "integrity": "sha512-A9hihu7dUTLOUCM+I8E61V4kRXnN4DwYeK0DwCBydC1MqNI1PidyAtbtpsJlBBzK4icSctEcCQ1bGcLpBuETUQ==", "dev": true }, + "randexp": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "requires": { + "drange": "^1.0.2", + "ret": "^0.2.0" + } + }, "range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -25592,6 +25763,16 @@ "integrity": "sha512-6K/gDlqgQscOlg9fSRpWstA8sYe8rbELsSTNpx+3kTrsVCzvSl0zIvRErM7fdl9ERWDsKnrLnwB+Ne89918XOg==", "dev": true }, + "ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==" + }, + "retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==" + }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", diff --git a/package.json b/package.json index f7ff495f..116a2b0d 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "@js-soft/node-logger": "1.0.3", "@js-soft/ts-utils": "^2.3.1", "@nmshd/runtime": "2.3.6", + "@nmshd/content": "*", + "async-retry": "^1.3.3", "agentkeepalive": "4.2.1", "amqplib": "^0.10.3", "axios": "^1.3.0", @@ -55,6 +57,7 @@ "nconf": "0.12.0", "on-headers": "1.0.2", "rapidoc": "9.3.4", + "randexp": "^0.5.3", "reflect-metadata": "0.1.13", "swagger-ui-express": "4.6.0", "typescript-ioc": "3.2.2", @@ -63,10 +66,12 @@ "yamljs": "0.3.0" }, "devDependencies": { + "@types/async-retry": "^1.4.4", "@js-soft/eslint-config-ts": "1.6.2", "@js-soft/license-check": "1.0.6", "@types/amqplib": "^0.10.1", "@types/compression": "^1.7.2", + "@types/config": "0.0.41", "@types/cors": "^2.8.13", "@types/express": "4.17.16", "@types/jest": "^29.4.0", From a265f123f615795309841f56951953e8fd9a76df Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Wed, 1 Mar 2023 15:54:27 +0100 Subject: [PATCH 02/34] chore: add keycloak to dev docker file to enable testing --- .dev/docker-compose.debug.yml | 25 +++++++++++++++++++++++++ .dev/keycloak.conf | 15 +++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 .dev/keycloak.conf diff --git a/.dev/docker-compose.debug.yml b/.dev/docker-compose.debug.yml index 4c6b3b9a..db8de3c2 100644 --- a/.dev/docker-compose.debug.yml +++ b/.dev/docker-compose.debug.yml @@ -18,6 +18,30 @@ services: - mongo-express stdin_open: true tty: true + + keycloaky: + container_name: keycloaky + image: quay.io/keycloak/keycloak:latest + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: Pa55w0rd + ports: + - 8081:8080 + volumes: + - ./keycloak.conf:/opt/keycloak/conf/keycloak.conf:ro + command: start-dev + depends_on: + - postgresy + + postgresy: + container_name: postgresy + image: postgres + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: password bc-2: build: @@ -70,3 +94,4 @@ services: volumes: mongo_data: + postgres_data: diff --git a/.dev/keycloak.conf b/.dev/keycloak.conf new file mode 100644 index 00000000..04e4ec6a --- /dev/null +++ b/.dev/keycloak.conf @@ -0,0 +1,15 @@ +# Basic settings for running in production. Change accordingly before deploying the server. + +# Database +db=postgres +db-username=keycloak +db-password=password +db-url=jdbc:postgresql://postgresy:5432/keycloak + +# Misc +features=token-exchange,admin_fine_grained_authz +http-enabled=true +hostname-strict=false + +# for prod +# proxy=passthrough From a64d4bd9c000314ebf128eb8fa9da27f055d8386 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Wed, 1 Mar 2023 15:56:58 +0100 Subject: [PATCH 03/34] chore: configure module settings --- config/default.json | 7 +++++++ config/dev.json | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/config/default.json b/config/default.json index 1102c0bd..7aaddce4 100644 --- a/config/default.json +++ b/config/default.json @@ -99,6 +99,13 @@ "enabled": false, "displayName": "AMQP Publisher", "location": "amqpPublisher/AMQPPublisherModule" + }, + "idpOnboarding": { + "enabled": false, + "displayName": "Onboarding", + "location": "onboarding/Onboarding", + + "requiredInfrastructure": ["httpServer"] } } } diff --git a/config/dev.json b/config/dev.json index ebfcb5b5..8e34d998 100644 --- a/config/dev.json +++ b/config/dev.json @@ -1,8 +1,20 @@ { "transportLibrary": { - "baseUrl": "http://dev.enmeshed.eu" + "baseUrl": "https://bird.enmeshed.eu" }, "modules": { - "amqpPublisher": { "url": "amqp://rabbitmq", "exchange": "nmshd" } + "amqpPublisher": { "url": "amqp://rabbitmq", "exchange": "nmshd" }, + "idpOnboarding": { + "enabled": true, + "baseUrl": "http://keycloaky:8080", + "realm": "demo", + "client": "demo-client", + "admin": { + "username": "admin", + "password": "Pa55w0rd" + }, + "passwordStrategy": "randomPassword", + "userIdStrategy": "custom" + } } } From bb9b0205e7bc6cea26b95a32cb54aeda5e695cb5 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Wed, 1 Mar 2023 15:57:27 +0100 Subject: [PATCH 04/34] chore: define idp interface --- src/modules/onboarding/IdentityProvider.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/modules/onboarding/IdentityProvider.ts diff --git a/src/modules/onboarding/IdentityProvider.ts b/src/modules/onboarding/IdentityProvider.ts new file mode 100644 index 00000000..7fb48035 --- /dev/null +++ b/src/modules/onboarding/IdentityProvider.ts @@ -0,0 +1,14 @@ +import { ResponseJSON } from "@nmshd/content"; + +export interface IdentityProvider { + initialize(): Promise; + onboard(change: ResponseJSON, userId: string): Promise; + register(change: ResponseJSON, userId: string, password: string): Promise; + getUser(userId: string): Promise; + getExistingUserInfo(userId: string, requestedData: string[]): Promise>; +} + +export enum Result { + Success, + Error +} From cb13ca62dfcadf7506749aa88d7414ed18959dfe Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Wed, 1 Mar 2023 15:57:42 +0100 Subject: [PATCH 05/34] chore: define onboarding specific config --- .../onboarding/OnboardingModuleConfig.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/modules/onboarding/OnboardingModuleConfig.ts diff --git a/src/modules/onboarding/OnboardingModuleConfig.ts b/src/modules/onboarding/OnboardingModuleConfig.ts new file mode 100644 index 00000000..0041e971 --- /dev/null +++ b/src/modules/onboarding/OnboardingModuleConfig.ts @@ -0,0 +1,23 @@ +import { ConnectorRuntimeModuleConfiguration } from "../../ConnectorRuntimeModule"; + +export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration { + baseUrl: string; + realm: string; + client: string; + admin: { + username: string; + password: string; + }; + passwordStrategy: "securePassword" | "randomPassword" | "ownPassword"; + userIdStrategy: "enmeshedAddress" | "custom" | "relationshipId"; + // The userData string list should contain the data that should be requested, if not allready present in the onboarding case, in enmeshed datatypes. + // The implementation of the IdentityProvider Interface that is being used is responsible for translating between the given enmeshed datatypes and the IDP datatypes + // Fields that cannot be parced to a enmeshed datatype should result in an error on startup since it is likely a configuration mistake which would leed + // to unexpected behaviour. + userData: + | { + req: string[] | undefined; + opt: string[] | undefined; + } + | undefined; +} From 65a2d00f575ae228429a5e4ab1b14faf89a791f5 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Wed, 1 Mar 2023 15:58:15 +0100 Subject: [PATCH 06/34] chore: define KeycloakUser --- src/modules/onboarding/KeycloakUser.ts | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/modules/onboarding/KeycloakUser.ts diff --git a/src/modules/onboarding/KeycloakUser.ts b/src/modules/onboarding/KeycloakUser.ts new file mode 100644 index 00000000..047be037 --- /dev/null +++ b/src/modules/onboarding/KeycloakUser.ts @@ -0,0 +1,30 @@ +export interface KeycloakUser extends Record { + id: string; + createdTimestamp: number; + username: string; + enabled: boolean; + totp: boolean; + emailVerified: boolean; + email: string; + attributes?: UserAttributes; + disableableCredentialTypes: unknown[]; + requiredActions: unknown[]; + notBefore: number; + access: UserAccess; +} + +export interface KeycloakUserWithRoles extends KeycloakUser { + roles: string[]; +} + +export interface UserAttributes extends Record { + enmeshedAddress?: string; +} + +export interface UserAccess { + manageGroupMembership: boolean; + view: boolean; + mapRoles: boolean; + impersonate: boolean; + manage: boolean; +} From 04c451780514103e587aad7fca4b04148c37a874 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Wed, 1 Mar 2023 15:58:31 +0100 Subject: [PATCH 07/34] feat: implement onboarding module with keycloak as idp --- src/modules/onboarding/Keycloak.ts | 536 +++++++++++++++++++++++++++ src/modules/onboarding/Onboarding.ts | 405 ++++++++++++++++++++ 2 files changed, 941 insertions(+) create mode 100644 src/modules/onboarding/Keycloak.ts create mode 100644 src/modules/onboarding/Onboarding.ts diff --git a/src/modules/onboarding/Keycloak.ts b/src/modules/onboarding/Keycloak.ts new file mode 100644 index 00000000..f1ca48a2 --- /dev/null +++ b/src/modules/onboarding/Keycloak.ts @@ -0,0 +1,536 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { ResponseItemGroupJSON, ResponseJSON } from "@nmshd/content"; +import AsyncRetry from "async-retry"; +import { AxiosInstance } from "axios"; +import { IdentityProvider, Result } from "./IdentityProvider"; +import { KeycloakUserWithRoles } from "./KeycloakUser"; +import { OnboardingModuleConfig } from "./OnboardingModuleConfig"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const randExp = require("randexp"); + +export enum RegistrationType { + Newcommer, + Onboarding +} + +export class Keycloak implements IdentityProvider { + public constructor(private readonly config: OnboardingModuleConfig, private readonly axios: AxiosInstance) {} + + public async initialize(): Promise { + const token = await AsyncRetry(async () => await this.getAdminToken("master"), { + retries: 5, + minTimeout: 5000 + }); + if (!(await this.isRealmSetup(token))) { + await this.setupRealm(token); + } + let client; + if (!(client = await this.isClientSetup(token))) { + await this.setupClient(token); + } else if (!this.isClientConfigCorrect(client)) { + await this.updateClientConfig(client, token); + } + const clientId = await this.checkPermissions(token); + if (clientId) await this.configurePermissions(clientId, token); + if (!(await this.hasAdminUser(token))) { + await this.createAdminUser(token); + } + } + + public async onboard(change: ResponseJSON, userId: string): Promise { + const userData = getUserData(change, userId); + + const status = await this.updateUser(userData); + + if (status !== 204) { + return Result.Error; + } + return Result.Success; + } + + public async register(change: ResponseJSON, userId: string, password: string): Promise { + const userData = getUserData(change, userId); + + const status = await this.createUser({ + ...userData, + ...{ password: password } + }); + if (status !== 201) { + return Result.Error; + } + return Result.Success; + } + + public async getExistingUserInfo(userId: string, requestedData: string[]): Promise> { + const user = await this.getUser(userId); + + const res: Map = new Map(); + + if (!user) { + return new Map(); + } + + const normalKeycloakAttributesMap: any = { + Surname: "lastName", + GivenName: "firstName", + EMailAddress: "email", + Sex: "gender", + PhoneNumber: "phone" + }; + + for (const element of requestedData) { + const keycloakName: string | undefined = normalKeycloakAttributesMap[element]; + if (keycloakName && user[keycloakName]) { + res.set(element, user[keycloakName]); + } else if (user.attributes?.[element]) { + res.set(element, user.attributes[element]); + } + } + + return res; + } + + private async updateUser(params: { + userName: string; + password?: string; + vorName?: string; + name?: string; + email?: string; + attributes?: Record; + addRoles?: string[]; + removeRoles?: string[]; + }): Promise { + const adminToken = await this.getAdminToken(this.config.realm); + + const user = await this.getUser(params.userName); + + const credentials = params.password ? [{ type: "password", value: params.password }] : undefined; + + if (!user) { + return 404; + } + + const response = await this.axios.put( + `/admin/realms/${this.config.realm}/users/${user.id}`, + { + username: params.userName, + firstName: params.vorName, + lastName: params.name, + email: params.email, + attributes: params.attributes, + credentials: credentials + }, + { + headers: { + authorization: `bearer ${adminToken}`, + "content-type": "application/json" + } + } + ); + + return response.status; + } + + private async createUser(params: { + userName: string; + password: string; + firstName?: string; + lastName?: string; + email?: string; + attributes?: Record; + roles?: string[]; + }): Promise { + try { + const adminToken = await this.getAdminToken(this.config.realm); + + const response = await this.axios.post( + `/admin/realms/${this.config.realm}/users`, + { + username: params.userName, + enabled: true, + firstName: params.firstName, + lastName: params.lastName, + email: params.email, + attributes: params.attributes, + credentials: [{ type: "password", value: params.password }] + }, + { + headers: { + authorization: `bearer ${adminToken}`, + "Content-Type": "application/json" + } + } + ); + + return response.status; + } catch (e: any) { + return e.status; + } + } + + public async getUser(userName: string): Promise { + const adminToken = await this.getAdminToken(this.config.realm); + + const response = await this.axios.get(`/admin/realms/${this.config.realm}/users?exact=true&username=${userName}`, { + headers: { authorization: `Bearer ${adminToken}` } + }); + const user: KeycloakUserWithRoles | undefined = response.data[0]; + if (!user) return; + const roleMappingResponse = await this.axios.get(`/admin/realms/${this.config.realm}/users/${user.id}/role-mappings/realm`, { + headers: { authorization: `Bearer ${adminToken}` } + }); + + user.roles = roleMappingResponse.data.map((el: any) => el.name); + return user; + } + + private async createAdminUser(token: string): Promise { + try { + await this.axios.post( + `/admin/realms/${this.config.realm}/users`, + { + username: this.config.admin.username, + credentials: [{ type: "password", value: this.config.admin.password }], + enabled: true + }, + { + headers: { + authorization: `bearer ${token}`, + "Content-Type": "application/json" + } + } + ); + const user = await this.axios.get(`/admin/realms/${this.config.realm}/users?exact=true&username=${this.config.admin.username}`, { + headers: { authorization: `Bearer ${token}` } + }); + const clientResponse = await this.axios.get(`/admin/realms/${this.config.realm}/clients`, { + headers: { authorization: `Bearer ${token}` } + }); + + const realmManagementClient = clientResponse.data.filter((el: any) => { + return el.clientId === "realm-management"; + })[0]; + + const roles = await this.axios.get(`/admin/realms/${this.config.realm}/clients/${realmManagementClient.id}/roles`, { + headers: { authorization: `Bearer ${token}` } + }); + + await this.axios.post(`/admin/realms/${this.config.realm}/users/${user.data[0].id}/role-mappings/clients/${realmManagementClient.id}`, roles.data, { + headers: { + authorization: `bearer ${token}`, + "Content-Type": "application/json" + } + }); + } catch (e) { + throw new Error(`Error creating admin User: \n${e}`); + } + } + + private async hasAdminUser(token: string): Promise { + const user = await this.axios.get(`/admin/realms/${this.config.realm}/users?exact=true&username=${this.config.admin.username}`, { + headers: { authorization: `Bearer ${token}` } + }); + return user.data.length > 0; + } + + private async configurePermissions(id: string, token: string) { + try { + // Enable permissions for the Client + await this.axios.put( + `/admin/realms/${this.config.realm}/clients/${id}/management/permissions`, + { + enabled: true + }, + { + headers: { authorization: `Bearer ${token}` } + } + ); + + // Get realm-management and admin-cli Client id + const clientResponse = await this.axios.get(`/admin/realms/${this.config.realm}/clients`, { + headers: { authorization: `Bearer ${token}` } + }); + const clientIds = clientResponse.data.filter((el: any) => { + return el.clientId === "realm-management" || el.clientId === "admin-cli"; + }); + + const realmManagementClient = clientIds.find((o: any) => o.clientId === "realm-management"); + const adminCliClient = clientIds.find((o: any) => o.clientId === "admin-cli"); + // Create token exchange policy + const policyId = new randExp(/\w{20}/).gen(); + await this.axios.post( + `/admin/realms/${this.config.realm}/clients/${realmManagementClient.id}/authz/resource-server/policy/client`, + { + id: policyId, + type: "client", + logic: "POSITIVE", + decisionStrategy: "UNANIMOUS", + name: "token-exchange", + clients: [adminCliClient.id] + }, + { + headers: { + authorization: `bearer ${token}`, + "Content-Type": "application/json" + } + } + ); + + // Get token exchange scope ID + const scopeIds = await this.axios.get(`/admin/realms/${this.config.realm}/clients/${realmManagementClient.id}/authz/resource-server/scope`, { + headers: { authorization: `Bearer ${token}` } + }); + const tokenExchangeScopeId = scopeIds.data.find((el: any) => el.name === "token-exchange").id; + + const policyIds = await this.axios.get(`/admin/realms/${this.config.realm}/clients/${realmManagementClient.id}/authz/resource-server/policy`, { + headers: { authorization: `Bearer ${token}` } + }); + const tokenExchangePolicy = policyIds.data.find((el: any) => el.name.startsWith("token-exchange.permission.client.")); + + // Get resourceId + const resourceIds = await this.axios.get(`/admin/realms/${this.config.realm}/clients/${realmManagementClient.id}/authz/resource-server/resource`, { + headers: { authorization: `Bearer ${token}` } + }); + const clientResourceId = resourceIds.data.find((el: any) => el.name.startsWith("client.resource."))._id; + // Activate token-exchange policy + await this.axios.put( + `/admin/realms/${this.config.realm}/clients/${realmManagementClient.id}/authz/resource-server/permission/scope/${tokenExchangePolicy.id}`, + { + decisionStrategy: "UNANIMOUS", + id: tokenExchangePolicy.id, + logic: "POSITIVE", + name: tokenExchangePolicy.name, + scopes: [tokenExchangeScopeId], + resources: [clientResourceId], + policies: [policyId], + type: "scope" + }, + { + headers: { authorization: `Bearer ${token}` } + } + ); + } catch (e) { + throw new Error(`Error updating client permissions.\n${e}`); + } + } + + private async checkPermissions(token: string): Promise { + // check if permissions are enabled + const clientResponse = await this.axios.get(`/admin/realms/${this.config.realm}/clients`, { + headers: { authorization: `Bearer ${token}` } + }); + const client = clientResponse.data.filter((el: any) => { + return el.clientId === this.config.client; + })[0]; + const clientPermissions = await this.axios.get(`/admin/realms/${this.config.realm}/clients/${client.id}/management/permissions`, { + headers: { authorization: `Bearer ${token}` } + }); + if (!clientPermissions.data.enabled) { + return client.id; + } + + // check if there is a token exchange policy + const realmManagementClient = clientResponse.data.filter((el: any) => { + return el.clientId === "realm-management"; + })[0]; + const policyIds = await this.axios.get(`/admin/realms/${this.config.realm}/clients/${realmManagementClient.id}/authz/resource-server/policy`, { + headers: { authorization: `Bearer ${token}` } + }); + if (policyIds.data.some((el: any) => el.name.startsWith("token-exchange.permission.client."))) { + return; + } + return client.id; + } + + private async updateClientConfig(client: any, token: string): Promise { + try { + await this.axios.put( + `/admin/realms/${this.config.realm}/clients/${client.id}`, + { + standardFlowEnabled: false, + directAccessGrantsEnabled: true, + publicClient: true, + webOrigins: ["*"] + }, + { + headers: { + authorization: `bearer ${token}`, + "Content-Type": "application/json" + } + } + ); + } catch (e) { + throw new Error(`Something went wrong updating the Client ${this.config.client} 😢\nPlease make sure the Keycloakserver is running`); + } + } + + private isClientConfigCorrect(client: any): boolean { + return client.webOrigins.includes("*") && !client.standardFlowEnabled && client.directAccessGrantsEnabled && client.publicClient; + } + + private async setupClient(token: string): Promise { + try { + await this.axios.post( + `/admin/realms/${this.config.realm}/clients`, + { + clientId: `${this.config.client}`, + standardFlowEnabled: false, + directAccessGrantsEnabled: true, + publicClient: true, + webOrigins: ["*"] + }, + { + headers: { + authorization: `bearer ${token}`, + "Content-Type": "application/json" + } + } + ); + } catch (e) { + throw new Error(`Something went wrong creating the Client ${this.config.client} 😢\nPlease make sure the Keycloakserver is running`); + } + } + + private async isClientSetup(token: string): Promise { + try { + let client: any; + const clientResponse = await this.axios.get(`/admin/realms/${this.config.realm}/clients`, { + headers: { authorization: `Bearer ${token}` } + }); + + if ( + !clientResponse.data.some((el: any) => { + if (el.clientId === this.config.client) { + client = el; + return true; + } + return false; + }) + ) { + return; + } + return client; + } catch (e) { + throw new Error(`Something went wrong checking for ${this.config.client}, error:\n${e}`); + } + } + + private async setupRealm(token: string): Promise { + try { + await this.axios.post( + "/admin/realms", + { + realm: `${this.config.realm}`, + enabled: true + }, + { + headers: { + authorization: `bearer ${token}`, + "Content-Type": "application/json" + } + } + ); + } catch (e) { + throw new Error(`Something went wrong creating the Realm ${this.config.realm} 😢\nPlease make sure the Keycloakserver is running`); + } + } + + private async isRealmSetup(token: string): Promise { + try { + const realm = await this.axios.get(`/realms/${this.config.realm}`, { + headers: { authorization: `Bearer ${token}` } + }); + if (realm.data.error) { + return false; + } + if (!realm.data.enabled) { + await this.axios.put( + `/admin/realms/${this.config.realm}`, + { + enabled: true + }, + { + headers: { + authorization: `bearer ${token}`, + "Content-Type": "application/json" + } + } + ); + } + return true; + } catch (e) { + return false; + } + } + + private async getAdminToken(realm: string): Promise { + const urlencoded = new URLSearchParams(); + urlencoded.append("client_id", "admin-cli"); + urlencoded.append("username", this.config.admin.username); + urlencoded.append("password", this.config.admin.password); + urlencoded.append("grant_type", "password"); + + const response = await this.axios.post(`/realms/${realm}/protocol/openid-connect/token`, urlencoded); + const json: any = await response.data; + return json.access_token; + } +} + +function getUserData( + request: ResponseJSON, + userId: string +): { + userName: string; + attributes?: any; + firstName?: string; + lastName?: string; + email?: string; +} { + const retValue = { + userName: userId, + attributes: {}, + firstName: undefined, + lastName: undefined, + email: undefined + }; + + const normalKeycloakAttributes = ["Surname", "GivenName", "EMailAddress"]; + + const entries = request.items.slice(1) as ResponseItemGroupJSON[]; + + const attr: any = {}; + + for (const entry of entries) { + for (const item of entry.items) { + if (item["@type"] === "ReadAttributeAcceptResponseItem" || item["@type"] === "ProposeAttributeAcceptResponseItem") { + const el: any = (item as any).attribute; + if (el?.value) { + if (!attr.enmeshedAddress) { + Object.assign(attr, { enmeshedAddress: el.owner }); + } + if (normalKeycloakAttributes.includes(el.value["@type"])) { + switch (el.value["@type"]) { + case "Surname": + retValue.lastName = el.value.value; + break; + case "GivenName": + retValue.firstName = el.value.value; + break; + case "EMailAddress": + retValue.email = el.value.value; + break; + default: + throw new Error("This is not possible"); + } + } else { + Object.assign(attr, { [el.value["@type"]]: el.value.value }); + } + } + } + } + } + + Object.assign(retValue.attributes, attr); + + return retValue; +} diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts new file mode 100644 index 00000000..a1d38491 --- /dev/null +++ b/src/modules/onboarding/Onboarding.ts @@ -0,0 +1,405 @@ +import { CreateAttributeRequestItemJSON, RelationshipAttributeConfidentiality, RequestItemGroupJSON, RequestItemJSONDerivations, RequestJSON, ResponseJSON } from "@nmshd/content"; +import { OutgoingRequestCreatedAndCompletedEvent } from "@nmshd/runtime"; +import AgentKeepAlive, { HttpsAgent } from "agentkeepalive"; +import axios from "axios"; +import { ParamsDictionary, Request, Response } from "express-serve-static-core"; +import { DateTime } from "luxon"; +import { ParsedQs } from "qs"; +import { ConnectorRuntimeModule } from "../../ConnectorRuntimeModule"; +import { HttpMethod } from "../../infrastructure"; +import { IdentityProvider, Result } from "./IdentityProvider"; +import { Keycloak, RegistrationType } from "./Keycloak"; +import { OnboardingModuleConfig } from "./OnboardingModuleConfig"; + +export default class Onboarding extends ConnectorRuntimeModule { + private idp: IdentityProvider; + + public async init(): Promise { + const axiosInstance = axios.create({ + baseURL: this.configuration.baseUrl, + httpAgent: new AgentKeepAlive(), + httpsAgent: new HttpsAgent(), + validateStatus: () => true, + maxRedirects: 0 + }); + + this.idp = new Keycloak(this.configuration, axiosInstance); + + try { + await this.idp.initialize(); + } catch (e: any) { + const err = new Error("Keycloak connection / setup was not successfull"); + err.stack = e.stack; + throw err; + } + this.runtime.infrastructure.httpServer.addEndpoint(HttpMethod.Get, "/onboardingQR", false, async (req, res) => { + await this.handleOnboardingQrRequest(req, res); + }); + this.runtime.infrastructure.httpServer.addEndpoint(HttpMethod.Get, "/registrationQR", false, async (req, res) => { + await this.handleRegistrationQrRequest(req, res); + }); + } + + public start(): void { + this.subscribeToEvent(OutgoingRequestCreatedAndCompletedEvent, this.handleOutgoingRequestCreatedAndCompleted.bind(this)); + } + + private async handleOutgoingRequestCreatedAndCompleted(event: OutgoingRequestCreatedAndCompletedEvent) { + const data = event.data; + const responseSourceType = data.response?.source?.type; + if (!responseSourceType || responseSourceType === "Message") { + // We only care about Relationship Changes + return; + } + + const changeId = data.response!.source!.reference; + + const templateId = data.source!.reference; + + const relationship = (await this.runtime.transportServices.relationships.getRelationship({ id: templateId })).value; + + const template = (await this.runtime.transportServices.relationshipTemplates.getRelationshipTemplate({ id: templateId })).value; + + const metadata: any = ( + template.content as { + "@type": "RelationshipTemplateContent"; + title?: string; + metadata?: object; + onNewRelationship: any; + onExistingRelationship?: any; + } + ).metadata; + + if (!metadata?.__createdByConnectorModule) { + // We only care about relationships changes initiated by our module which are marked in the metadata + return; + } + + const itemGroup = data.content.items[0] as RequestItemGroupJSON; + + const username = ((itemGroup.items[1] as CreateAttributeRequestItemJSON).attribute.value as any).value as string; + + const type = metadata.type; + + if (!type) { + // Relationship changes we initiatet have tho type meta tag + return; + } + + const change: ResponseJSON = data.response!.content; + + // TODO: At the end of handling the event we have to initiate our own event to trigger the webhook informing the endclient about the result. + switch (type) { + case RegistrationType.Newcommer: + let password: string; + switch (this.configuration.passwordStrategy) { + case "securePassword": { + // TODO: implement secure password generation + password = "secure"; + } + case "randomPassword": { + // TODO: implement random password generation + password = "random"; + } + case "ownPassword": { + password = metadata.password; + } + } + const registrationResult = await this.idp.register(change, username, password); + switch (registrationResult) { + case Result.Success: { + await this.runtime.transportServices.relationships.acceptRelationshipChange({ relationshipId: relationship.id, changeId, content: undefined }); + } + case Result.Error: { + await this.runtime.transportServices.relationships.rejectRelationshipChange({ relationshipId: relationship.id, changeId, content: undefined }); + } + } + break; + case RegistrationType.Onboarding: + const onboardingResult = await this.idp.onboard(change, username); + switch (onboardingResult) { + case Result.Success: { + await this.runtime.transportServices.relationships.acceptRelationshipChange({ relationshipId: relationship.id, changeId, content: undefined }); + } + case Result.Error: { + await this.runtime.transportServices.relationships.rejectRelationshipChange({ relationshipId: relationship.id, changeId, content: undefined }); + } + } + break; + } + } + + private async handleOnboardingQrRequest( + req: Request>, + res: Response, number> + ): Promise { + const query = req.query; + + const user = await this.idp.getUser(query.userId as string); + + if (query.userId && user) { + const qrBytes: ArrayBuffer = await this.createRegistrationQRCode(RegistrationType.Newcommer, query.userId as string, query.sId as string | undefined); + + return res.send(arrayBufferToStringArray(qrBytes)).status(200); + } + res.status(404).send("User not found!"); + } + + private async handleRegistrationQrRequest( + req: Request>, + res: Response, number> + ): Promise { + const query = req.query; + let password: string | undefined; + switch (this.configuration.passwordStrategy) { + case "ownPassword": { + if (!query.password) { + return res + .status(400) + .send( + "The module is configured in a way so that you need to pass a password, that will be used to create the account, in order to create a account with enmeshed." + ); + } + password = query.password as string; + } + default: { + // Nothing to do here for us since the password will be generated automatically when the relationship is accepted + } + } + + if (!query.userId && this.configuration.userIdStrategy === "custom") { + return res.status(400).send("To create a username with the custom userIdStrategy you need to pass it"); + } + + const qrBytes: ArrayBuffer = await this.createRegistrationQRCode(RegistrationType.Newcommer, query.userId as string | undefined, query.sId as string | undefined, password); + + return res.status(200).send(arrayBufferToStringArray(qrBytes)); + } + + private async createRegistrationQRCode(type: RegistrationType, userId?: string, sId?: string, password?: string): Promise { + const identity = (await this.runtime.transportServices.account.getIdentityInfo()).value; + + const sharableDisplayName = await this.getOrCreateConnectorDisplayName(identity.address, this.configuration.displayName); + + const createItems: RequestItemJSONDerivations[] = [ + { + "@type": "ShareAttributeRequestItem", + mustBeAccepted: true, + attribute: { ...sharableDisplayName.content, owner: "" }, + sourceAttributeId: sharableDisplayName.id + } + ]; + + const proposedItems: RequestItemJSONDerivations[] = []; + + const requestItems: RequestItemJSONDerivations[] = []; + + if (userId) { + createItems.push({ + "@type": "CreateAttributeRequestItem", + mustBeAccepted: true, + attribute: { + "@type": "RelationshipAttribute", + owner: identity.address, + key: "userName", + value: { + "@type": "ProprietaryString", + title: "TODO", + value: userId + }, + isTechnical: false, + confidentiality: RelationshipAttributeConfidentiality.Public + } + }); + } + + let requestedData: string[] = []; + + if (this.configuration.userData?.req) { + requestedData = requestedData.concat(this.configuration.userData.req); + } + + if (this.configuration.userData?.opt) { + requestedData = requestedData.concat(this.configuration.userData.opt); + } + + let existingValues: Map; + + if (type === RegistrationType.Onboarding) { + existingValues = await this.idp.getExistingUserInfo(userId!, requestedData); + } else { + existingValues = new Map(); + } + + this.configuration.userData?.req?.forEach((element) => { + const proposedValue = existingValues.get(element); + if (proposedValue) { + proposedItems.push({ + "@type": "ProposeAttributeRequestItem", + attribute: { + "@type": "IdentityAttribute", + owner: "", + value: { + "@type": "IdentityAttributeQuery" as any, + value: proposedValue + } + }, + query: { + "@type": "IdentityAttributeQuery", + valueType: element as any + }, + mustBeAccepted: true + }); + } else { + requestItems.push({ + "@type": "ReadAttributeRequestItem", + query: { + "@type": "IdentityAttributeQuery", + valueType: element as any + }, + mustBeAccepted: true + }); + } + }); + + this.configuration.userData?.opt?.forEach((optionalElement) => { + const proposedValue = existingValues.get(optionalElement); + if (proposedValue) { + proposedItems.push({ + "@type": "ProposeAttributeRequestItem", + attribute: { + "@type": "IdentityAttribute", + owner: "", + value: { + "@type": "IdentityAttributeQuery" as any, + value: proposedValue + } + }, + query: { + "@type": "IdentityAttributeQuery", + valueType: optionalElement as any + }, + mustBeAccepted: false + }); + } else { + requestItems.push({ + "@type": "ReadAttributeRequestItem", + query: { + "@type": "IdentityAttributeQuery", + valueType: optionalElement as any + }, + mustBeAccepted: false + }); + } + }); + + const createObject: RequestItemGroupJSON = { + "@type": "RequestItemGroup", + mustBeAccepted: createItems.some((el) => el.mustBeAccepted), + title: "Shared Attributes", + items: createItems + }; + + const proposedObject: RequestItemGroupJSON = { + "@type": "RequestItemGroup", + mustBeAccepted: proposedItems.some((el) => el.mustBeAccepted), + title: "Requested Attributes", + items: proposedItems + }; + + const requestObject: RequestItemGroupJSON = { + "@type": "RequestItemGroup", + mustBeAccepted: requestItems.some((el) => el.mustBeAccepted), + title: "Requested Attributes", + items: requestItems + }; + + const filteredItemObject = [createObject, proposedObject, requestObject].filter((el) => el.items[0]); + + const onNewRelationship: RequestJSON = { + "@type": "Request", + items: filteredItemObject + }; + const requestPlausible = await this.runtime.consumptionServices.outgoingRequests.canCreate({ content: onNewRelationship }); + + if (!requestPlausible.value.isSuccess) { + return new ArrayBuffer(0); + } + // Template erstellen + const template = await this.runtime.transportServices.relationshipTemplates.createOwnRelationshipTemplate({ + maxNumberOfAllocations: 1, + content: { + "@type": "RelationshipTemplateContent", + title: "Connector Demo Contact", + metadata: { + // eslint-disable-next-line @typescript-eslint/naming-convention + __createdByConnectorModule: true, + webSessionId: sId, + type: type, + password + }, + onNewRelationship, + onExistingRelationship: { + metadata: { + webSessionId: sId, + type: type + }, + items: [ + { + "@type": "AuthenticationRequestItem", + title: "Login Request", + description: "There has been a login request if you did not initiate it please ignore this message and do not approve!", + mustBeAccepted: true, + reqireManualDecision: true + } + ] + } + }, + expiresAt: DateTime.now().plus({ days: 2 }).toISO() + }); + + const image = await this.runtime.transportServices.relationshipTemplates.createTokenQrCodeForOwnTemplate({ templateId: template.value.id }); + + return Buffer.from(image.value.qrCodeBytes, "base64"); + } + + private async getOrCreateConnectorDisplayName(connectorAddress: string, displayName: string) { + const response = await this.runtime.consumptionServices.attributes.getAttributes({ + query: { + "content.owner": connectorAddress, + "content.value.@type": "DisplayName" + } + }); + + if (response.value.length > 0) { + return response.value[0]; + } + + const createAttributeResponse = await this.runtime.consumptionServices.attributes.createAttribute({ + content: { + "@type": "IdentityAttribute", + owner: connectorAddress, + value: { + "@type": "DisplayName", + value: displayName + } + } + }); + + return createAttributeResponse.value; + } + + public stop(): void { + this.unsubscribeFromAllEvents(); + } +} + +function arrayBufferToStringArray(buffer: ArrayBuffer): string[] { + const uInt8A = new Uint8Array(buffer); + let i = uInt8A.length; + const biStr = []; + while (i--) { + biStr[i] = String.fromCharCode(uInt8A[i]); + } + return biStr; +} From 9981d01450e584b362439d7f1a293e25cb3025cb Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Thu, 2 Mar 2023 14:26:32 +0100 Subject: [PATCH 08/34] chore: adjust config --- .dev/docker-compose.debug.yml | 3 ++- config/dev.json | 38 ++++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.dev/docker-compose.debug.yml b/.dev/docker-compose.debug.yml index db8de3c2..965d709e 100644 --- a/.dev/docker-compose.debug.yml +++ b/.dev/docker-compose.debug.yml @@ -16,9 +16,10 @@ services: depends_on: - mongo - mongo-express + - keycloaky stdin_open: true tty: true - + keycloaky: container_name: keycloaky image: quay.io/keycloak/keycloak:latest diff --git a/config/dev.json b/config/dev.json index 8e34d998..3abc9f66 100644 --- a/config/dev.json +++ b/config/dev.json @@ -1,9 +1,45 @@ { "transportLibrary": { - "baseUrl": "https://bird.enmeshed.eu" + "baseUrl": "https://bird.enmeshed.eu", + "platformClientId": "dev", + "platformClientSecret": "SY3nxukl6Xn8kGDk52EwBKXZMR9OR5" + }, + "infrastructure": { + "httpServer": { + "cors": { + "origin": true + } + } }, "modules": { "amqpPublisher": { "url": "amqp://rabbitmq", "exchange": "nmshd" }, + "sync": { + "enabled": true, + "interval": 10 + }, + "webhooksV2": { + "enabled": true, + "targets": { + "onboard": { + "url": "http://localhost:2901/api/v1/test", + "headers": { "X-API-KEY": "xxx" } + }, + "register": { + "url": "http://localhost:2901/api/v1/test", + "headers": { "X-API-KEY": "xxx" } + } + }, + "webhooks": [ + { + "triggers": ["onboarding.onboardingCompleted"], + "target": "onboard" + }, + { + "triggers": ["onboarding.registrationCompleted"], + "target": "register" + } + ] + }, "idpOnboarding": { "enabled": true, "baseUrl": "http://keycloaky:8080", From df413e51ed13e4013835c75f1bec621ea18c0e3e Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Thu, 2 Mar 2023 14:29:07 +0100 Subject: [PATCH 09/34] feat: implement registration and onboarding with keycloak --- src/modules/onboarding/Onboarding.ts | 64 +++++++++++-------- .../events/OnboardingCompletedEvent.ts | 13 ++++ .../events/RegistrationCompletedEvent.ts | 14 ++++ src/modules/onboarding/events/index.ts | 2 + .../IdentityProvider.ts | 0 .../IdentityProviderConfig.ts} | 4 +- .../KeycloakIdentityProvider.ts} | 24 +++++-- .../onboarding/identityProviders/index.ts | 3 + 8 files changed, 89 insertions(+), 35 deletions(-) create mode 100644 src/modules/onboarding/events/OnboardingCompletedEvent.ts create mode 100644 src/modules/onboarding/events/RegistrationCompletedEvent.ts create mode 100644 src/modules/onboarding/events/index.ts rename src/modules/onboarding/{ => identityProviders}/IdentityProvider.ts (100%) rename src/modules/onboarding/{OnboardingModuleConfig.ts => identityProviders/IdentityProviderConfig.ts} (84%) rename src/modules/onboarding/{Keycloak.ts => identityProviders/KeycloakIdentityProvider.ts} (96%) create mode 100644 src/modules/onboarding/identityProviders/index.ts diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index a1d38491..a81651f9 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -1,29 +1,20 @@ import { CreateAttributeRequestItemJSON, RelationshipAttributeConfidentiality, RequestItemGroupJSON, RequestItemJSONDerivations, RequestJSON, ResponseJSON } from "@nmshd/content"; import { OutgoingRequestCreatedAndCompletedEvent } from "@nmshd/runtime"; -import AgentKeepAlive, { HttpsAgent } from "agentkeepalive"; -import axios from "axios"; import { ParamsDictionary, Request, Response } from "express-serve-static-core"; import { DateTime } from "luxon"; import { ParsedQs } from "qs"; -import { ConnectorRuntimeModule } from "../../ConnectorRuntimeModule"; +import { ConnectorRuntimeModule, ConnectorRuntimeModuleConfiguration } from "../../ConnectorRuntimeModule"; import { HttpMethod } from "../../infrastructure"; -import { IdentityProvider, Result } from "./IdentityProvider"; -import { Keycloak, RegistrationType } from "./Keycloak"; -import { OnboardingModuleConfig } from "./OnboardingModuleConfig"; +import { OnboardingCompletedEvent, RegistrationCompletedEvent } from "./events"; +import { IdentityProvider, IdentityProviderConfig, KeycloakIdentityProvider, RegistrationType, Result } from "./identityProviders"; + +export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration, IdentityProviderConfig {} export default class Onboarding extends ConnectorRuntimeModule { private idp: IdentityProvider; public async init(): Promise { - const axiosInstance = axios.create({ - baseURL: this.configuration.baseUrl, - httpAgent: new AgentKeepAlive(), - httpsAgent: new HttpsAgent(), - validateStatus: () => true, - maxRedirects: 0 - }); - - this.idp = new Keycloak(this.configuration, axiosInstance); + this.idp = new KeycloakIdentityProvider(this.configuration); try { await this.idp.initialize(); @@ -56,7 +47,8 @@ export default class Onboarding extends ConnectorRuntimeModule { + private async createQRCode(type: RegistrationType, userId?: string, sId?: string, password?: string): Promise { const identity = (await this.runtime.transportServices.account.getIdentityInfo()).value; const sharableDisplayName = await this.getOrCreateConnectorDisplayName(identity.address, this.configuration.displayName); @@ -204,7 +218,7 @@ export default class Onboarding extends ConnectorRuntimeModule { + private static readonly namespace = "onboarding.onboardingCompleted"; + public constructor(data: OnboardingCompletedEventData) { + super(OnboardingCompletedEvent.namespace, data); + } +} diff --git a/src/modules/onboarding/events/RegistrationCompletedEvent.ts b/src/modules/onboarding/events/RegistrationCompletedEvent.ts new file mode 100644 index 00000000..183444db --- /dev/null +++ b/src/modules/onboarding/events/RegistrationCompletedEvent.ts @@ -0,0 +1,14 @@ +import { DataEvent } from "@js-soft/ts-utils"; + +export interface RegistrationCompletedEventData { + userId: string; + sessionId?: string; + password?: string; +} + +export class RegistrationCompletedEvent extends DataEvent { + private static readonly namespace = "onboarding.registrationCompleted"; + public constructor(data: RegistrationCompletedEventData) { + super(RegistrationCompletedEvent.namespace, data); + } +} diff --git a/src/modules/onboarding/events/index.ts b/src/modules/onboarding/events/index.ts new file mode 100644 index 00000000..c5fc1fe8 --- /dev/null +++ b/src/modules/onboarding/events/index.ts @@ -0,0 +1,2 @@ +export * from "./OnboardingCompletedEvent"; +export * from "./RegistrationCompletedEvent"; \ No newline at end of file diff --git a/src/modules/onboarding/IdentityProvider.ts b/src/modules/onboarding/identityProviders/IdentityProvider.ts similarity index 100% rename from src/modules/onboarding/IdentityProvider.ts rename to src/modules/onboarding/identityProviders/IdentityProvider.ts diff --git a/src/modules/onboarding/OnboardingModuleConfig.ts b/src/modules/onboarding/identityProviders/IdentityProviderConfig.ts similarity index 84% rename from src/modules/onboarding/OnboardingModuleConfig.ts rename to src/modules/onboarding/identityProviders/IdentityProviderConfig.ts index 0041e971..6865ef6d 100644 --- a/src/modules/onboarding/OnboardingModuleConfig.ts +++ b/src/modules/onboarding/identityProviders/IdentityProviderConfig.ts @@ -1,6 +1,4 @@ -import { ConnectorRuntimeModuleConfiguration } from "../../ConnectorRuntimeModule"; - -export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration { +export interface IdentityProviderConfig { baseUrl: string; realm: string; client: string; diff --git a/src/modules/onboarding/Keycloak.ts b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts similarity index 96% rename from src/modules/onboarding/Keycloak.ts rename to src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts index f1ca48a2..6f29ad8c 100644 --- a/src/modules/onboarding/Keycloak.ts +++ b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts @@ -1,20 +1,30 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { ResponseItemGroupJSON, ResponseJSON } from "@nmshd/content"; +import AgentKeepAlive, { HttpsAgent } from "agentkeepalive"; import AsyncRetry from "async-retry"; -import { AxiosInstance } from "axios"; +import axios, { AxiosInstance } from "axios"; +import { KeycloakUserWithRoles } from "../KeycloakUser"; import { IdentityProvider, Result } from "./IdentityProvider"; -import { KeycloakUserWithRoles } from "./KeycloakUser"; -import { OnboardingModuleConfig } from "./OnboardingModuleConfig"; +import { IdentityProviderConfig } from "./IdentityProviderConfig"; // eslint-disable-next-line @typescript-eslint/no-require-imports const randExp = require("randexp"); export enum RegistrationType { - Newcommer, - Onboarding + Newcommer = "Newcommer", + Onboarding = "Onboarding" } -export class Keycloak implements IdentityProvider { - public constructor(private readonly config: OnboardingModuleConfig, private readonly axios: AxiosInstance) {} +export class KeycloakIdentityProvider implements IdentityProvider { + private readonly axios: AxiosInstance; + public constructor(private readonly config: IdentityProviderConfig) { + this.axios = axios.create({ + baseURL: this.config.baseUrl, + httpAgent: new AgentKeepAlive(), + httpsAgent: new HttpsAgent(), + validateStatus: () => true, + maxRedirects: 0 + }); + } public async initialize(): Promise { const token = await AsyncRetry(async () => await this.getAdminToken("master"), { diff --git a/src/modules/onboarding/identityProviders/index.ts b/src/modules/onboarding/identityProviders/index.ts new file mode 100644 index 00000000..9e19e12a --- /dev/null +++ b/src/modules/onboarding/identityProviders/index.ts @@ -0,0 +1,3 @@ +export * from "./IdentityProvider"; +export * from "./IdentityProviderConfig"; +export * from "./KeycloakIdentityProvider"; From 17a4d306eb5ce14e553d1a399a7a1c2d595b4015 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Thu, 2 Mar 2023 15:15:49 +0100 Subject: [PATCH 10/34] feat: implement custom password --- config/dev.json | 2 +- src/modules/onboarding/Onboarding.ts | 38 ++++++++++++++++++---------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/config/dev.json b/config/dev.json index 3abc9f66..3c77010c 100644 --- a/config/dev.json +++ b/config/dev.json @@ -49,7 +49,7 @@ "username": "admin", "password": "Pa55w0rd" }, - "passwordStrategy": "randomPassword", + "passwordStrategy": "ownPassword", "userIdStrategy": "custom" } } diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index a81651f9..975e0714 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -12,9 +12,13 @@ export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfigurat export default class Onboarding extends ConnectorRuntimeModule { private idp: IdentityProvider; + private passwordStore?: Map; public async init(): Promise { this.idp = new KeycloakIdentityProvider(this.configuration); + if (this.configuration.passwordStrategy === "ownPassword") { + this.passwordStore = new Map(); + } try { await this.idp.initialize(); @@ -95,14 +99,14 @@ export default class Onboarding extends ConnectorRuntimeModule { + private async createQRCode(type: RegistrationType, userId?: string, sId?: string): Promise<[ArrayBuffer, string]> { const identity = (await this.runtime.transportServices.account.getIdentityInfo()).value; const sharableDisplayName = await this.getOrCreateConnectorDisplayName(identity.address, this.configuration.displayName); @@ -337,7 +348,7 @@ export default class Onboarding extends ConnectorRuntimeModule Date: Fri, 3 Mar 2023 10:42:57 +0100 Subject: [PATCH 11/34] feat: make Event failable --- src/modules/onboarding/Onboarding.ts | 50 +++++++++++++++++-- .../events/OnboardingCompletedEvent.ts | 11 ++-- .../events/RegistrationCompletedEvent.ts | 11 ++-- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index 975e0714..724d36f5 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -106,14 +106,31 @@ export default class Onboarding extends ConnectorRuntimeModule { +interface OnboardingResult { + success: boolean; + data: OnboardingCompletedEventData | undefined; +} + +export class OnboardingCompletedEvent extends DataEvent { private static readonly namespace = "onboarding.onboardingCompleted"; - public constructor(data: OnboardingCompletedEventData) { + public constructor(data: OnboardingResult) { super(OnboardingCompletedEvent.namespace, data); } } diff --git a/src/modules/onboarding/events/RegistrationCompletedEvent.ts b/src/modules/onboarding/events/RegistrationCompletedEvent.ts index 183444db..68438dba 100644 --- a/src/modules/onboarding/events/RegistrationCompletedEvent.ts +++ b/src/modules/onboarding/events/RegistrationCompletedEvent.ts @@ -1,14 +1,19 @@ import { DataEvent } from "@js-soft/ts-utils"; -export interface RegistrationCompletedEventData { +interface RegistrationCompletedEventData { userId: string; sessionId?: string; password?: string; } -export class RegistrationCompletedEvent extends DataEvent { +export interface RegistrationResult { + success: boolean; + data: RegistrationCompletedEventData | undefined; +} + +export class RegistrationCompletedEvent extends DataEvent { private static readonly namespace = "onboarding.registrationCompleted"; - public constructor(data: RegistrationCompletedEventData) { + public constructor(data: RegistrationResult) { super(RegistrationCompletedEvent.namespace, data); } } From 3d7fdcc2ffed2ba5b18402c35028f7c434a565af Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Fri, 3 Mar 2023 10:43:06 +0100 Subject: [PATCH 12/34] feat: add password generation --- package-lock.json | 2 ++ package.json | 1 + src/modules/onboarding/Onboarding.ts | 31 ++++++++++++++-------------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9683b56c..b82241fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@js-soft/node-logger": "1.0.3", "@js-soft/ts-utils": "^2.3.1", "@nmshd/content": "*", + "@nmshd/crypto": "2.0.3", "@nmshd/runtime": "2.3.6", "agentkeepalive": "4.2.1", "amqplib": "^0.10.3", @@ -12611,6 +12612,7 @@ "@nmshd/connector": "file:", "@nmshd/connector-sdk": "file:packages/sdk", "@nmshd/content": "*", + "@nmshd/crypto": "2.0.3", "@nmshd/runtime": "2.3.6", "@types/amqplib": "^0.10.1", "@types/async-retry": "^1.4.4", diff --git a/package.json b/package.json index 116a2b0d..967b6a3e 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@js-soft/ts-utils": "^2.3.1", "@nmshd/runtime": "2.3.6", "@nmshd/content": "*", + "@nmshd/crypto": "2.0.3", "async-retry": "^1.3.3", "agentkeepalive": "4.2.1", "amqplib": "^0.10.3", diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index 724d36f5..aa2a1530 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -1,4 +1,5 @@ import { CreateAttributeRequestItemJSON, RelationshipAttributeConfidentiality, RequestItemGroupJSON, RequestItemJSONDerivations, RequestJSON, ResponseJSON } from "@nmshd/content"; +import { CryptoPasswordGenerator } from "@nmshd/crypto"; import { OutgoingRequestCreatedAndCompletedEvent } from "@nmshd/runtime"; import { ParamsDictionary, Request, Response } from "express-serve-static-core"; import { DateTime } from "luxon"; @@ -89,13 +90,11 @@ export default class Onboarding extends ConnectorRuntimeModule Date: Fri, 3 Mar 2023 11:54:04 +0100 Subject: [PATCH 13/34] feat: make automatic setup opt in --- .../identityProviders/IdentityProviderConfig.ts | 1 + .../identityProviders/KeycloakIdentityProvider.ts | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/src/modules/onboarding/identityProviders/IdentityProviderConfig.ts b/src/modules/onboarding/identityProviders/IdentityProviderConfig.ts index 6865ef6d..58166833 100644 --- a/src/modules/onboarding/identityProviders/IdentityProviderConfig.ts +++ b/src/modules/onboarding/identityProviders/IdentityProviderConfig.ts @@ -18,4 +18,5 @@ export interface IdentityProviderConfig { opt: string[] | undefined; } | undefined; + automateSetup: boolean; } diff --git a/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts index 6f29ad8c..c18ffad9 100644 --- a/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts +++ b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts @@ -32,14 +32,19 @@ export class KeycloakIdentityProvider implements IdentityProvider { minTimeout: 5000 }); if (!(await this.isRealmSetup(token))) { + if (!this.config.automateSetup) throw new Error(`The given realm: ${this.config.realm} is not setup.`); await this.setupRealm(token); } let client; if (!(client = await this.isClientSetup(token))) { + if (!this.config.automateSetup) throw new Error(`The given client: ${this.config.client} is not setup.`); await this.setupClient(token); } else if (!this.isClientConfigCorrect(client)) { + if (!this.config.automateSetup) throw new Error(`The given client: ${this.config.client} is not configured correctly.`); await this.updateClientConfig(client, token); } + // This function will throw an error if the permissions are not set up correctly and autosetup is disabled. + // We do it there so the error message is as describing as possible. const clientId = await this.checkPermissions(token); if (clientId) await this.configurePermissions(clientId, token); if (!(await this.hasAdminUser(token))) { @@ -335,7 +340,9 @@ export class KeycloakIdentityProvider implements IdentityProvider { const clientPermissions = await this.axios.get(`/admin/realms/${this.config.realm}/clients/${client.id}/management/permissions`, { headers: { authorization: `Bearer ${token}` } }); + if (!clientPermissions.data.enabled) { + if (!this.config.automateSetup) throw new Error(`Client permissions are not enabled for client: ${this.config.client}`); return client.id; } @@ -349,6 +356,7 @@ export class KeycloakIdentityProvider implements IdentityProvider { if (policyIds.data.some((el: any) => el.name.startsWith("token-exchange.permission.client."))) { return; } + if (!this.config.automateSetup) throw new Error(`Client ${this.config.client} does not hate token-exchange permision`); return client.id; } From 05f1ef26a1e2a8fdf7d6a885b1cf0d30f41aa9c7 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Mon, 6 Mar 2023 15:26:31 +0100 Subject: [PATCH 14/34] feat: add Login event --- .../onboarding/events/LoginCompletedEvent.ts | 23 +++++++++++++++++++ src/modules/onboarding/events/index.ts | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 src/modules/onboarding/events/LoginCompletedEvent.ts diff --git a/src/modules/onboarding/events/LoginCompletedEvent.ts b/src/modules/onboarding/events/LoginCompletedEvent.ts new file mode 100644 index 00000000..dd87b011 --- /dev/null +++ b/src/modules/onboarding/events/LoginCompletedEvent.ts @@ -0,0 +1,23 @@ +import { DataEvent } from "@js-soft/ts-utils"; + +interface LoginResult { + success: boolean; + // If the relationship could not be found this will be undefined + data: TargetDetails | undefined; + // No matter if success or not the sessionId needs to be communicated to give feedback. + sessionId: string; +} + +interface TargetDetails { + // Even when the login is unsuccessful we should communicate what userId login was requested + target: string; + // The tokens are defined if sucess === true + tokens?: string; +} + +export class LoginCompletedEvent extends DataEvent { + private static readonly namespace = "onboarding.loginCompleted"; + public constructor(data: LoginResult) { + super(LoginCompletedEvent.namespace, data); + } +} diff --git a/src/modules/onboarding/events/index.ts b/src/modules/onboarding/events/index.ts index c5fc1fe8..6041e9a4 100644 --- a/src/modules/onboarding/events/index.ts +++ b/src/modules/onboarding/events/index.ts @@ -1,2 +1,3 @@ +export * from "./LoginCompletedEvent"; export * from "./OnboardingCompletedEvent"; -export * from "./RegistrationCompletedEvent"; \ No newline at end of file +export * from "./RegistrationCompletedEvent"; From 883bc1342a0624841db5eb044961337ff343cae0 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Mon, 6 Mar 2023 15:26:55 +0100 Subject: [PATCH 15/34] feat: add optional login feature --- config/dev.json | 3 ++- src/modules/onboarding/identityProviders/IdentityProvider.ts | 1 + .../onboarding/identityProviders/IdentityProviderConfig.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/config/dev.json b/config/dev.json index 3c77010c..b7627474 100644 --- a/config/dev.json +++ b/config/dev.json @@ -50,7 +50,8 @@ "password": "Pa55w0rd" }, "passwordStrategy": "ownPassword", - "userIdStrategy": "custom" + "userIdStrategy": "custom", + "login": true } } } diff --git a/src/modules/onboarding/identityProviders/IdentityProvider.ts b/src/modules/onboarding/identityProviders/IdentityProvider.ts index 7fb48035..2fd6af0f 100644 --- a/src/modules/onboarding/identityProviders/IdentityProvider.ts +++ b/src/modules/onboarding/identityProviders/IdentityProvider.ts @@ -5,6 +5,7 @@ export interface IdentityProvider { onboard(change: ResponseJSON, userId: string): Promise; register(change: ResponseJSON, userId: string, password: string): Promise; getUser(userId: string): Promise; + login?(userId: string): Promise; getExistingUserInfo(userId: string, requestedData: string[]): Promise>; } diff --git a/src/modules/onboarding/identityProviders/IdentityProviderConfig.ts b/src/modules/onboarding/identityProviders/IdentityProviderConfig.ts index 58166833..209913d4 100644 --- a/src/modules/onboarding/identityProviders/IdentityProviderConfig.ts +++ b/src/modules/onboarding/identityProviders/IdentityProviderConfig.ts @@ -18,5 +18,6 @@ export interface IdentityProviderConfig { opt: string[] | undefined; } | undefined; + login: boolean; automateSetup: boolean; } From f8533c6097ab0bd00a3a29bfc546e5313b624461 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Mon, 6 Mar 2023 15:27:26 +0100 Subject: [PATCH 16/34] feat: add impersonation for keycloak --- .../KeycloakIdentityProvider.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts index c18ffad9..672237bf 100644 --- a/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts +++ b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts @@ -76,6 +76,18 @@ export class KeycloakIdentityProvider implements IdentityProvider { return Result.Success; } + public async login(userId: string): Promise { + const user = await this.getUser(userId); + + if (!user) { + return undefined; + } + + const token = await this.impersonate(user.id); + + return token; + } + public async getExistingUserInfo(userId: string, requestedData: string[]): Promise> { const user = await this.getUser(userId); @@ -105,6 +117,25 @@ export class KeycloakIdentityProvider implements IdentityProvider { return res; } + // Impersonate User with admin token + private async impersonate(userId: string): Promise { + const adminToken = await this.getAdminToken(this.config.realm); + + const urlencoded = new URLSearchParams(); + urlencoded.append("client_id", "admin-cli"); + urlencoded.append("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"); + urlencoded.append("subject_token", adminToken); + urlencoded.append("requested_token_type", "urn:ietf:params:oauth:token-type:refresh_token"); + urlencoded.append("audience", this.config.client); + urlencoded.append("requested_subject", userId); + + const response = await this.axios.post(`/realms/${this.config.realm}/protocol/openid-connect/token`, urlencoded, { + headers: { authorization: `Bearer ${adminToken}` } + }); + + return response.data; + } + private async updateUser(params: { userName: string; password?: string; From 7c1eca953a481b58a05d1b5083e7628f0d625a45 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Mon, 6 Mar 2023 15:33:00 +0100 Subject: [PATCH 17/34] feat: add loginQR logic --- src/modules/onboarding/Onboarding.ts | 66 +++++++++++++++++++++------- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index aa2a1530..c696ec40 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -1,4 +1,12 @@ -import { CreateAttributeRequestItemJSON, RelationshipAttributeConfidentiality, RequestItemGroupJSON, RequestItemJSONDerivations, RequestJSON, ResponseJSON } from "@nmshd/content"; +import { + CreateAttributeRequestItemJSON, + ProprietaryStringJSON, + RelationshipAttributeConfidentiality, + RequestItemGroupJSON, + RequestItemJSONDerivations, + RequestJSON, + ResponseJSON +} from "@nmshd/content"; import { CryptoPasswordGenerator } from "@nmshd/crypto"; import { OutgoingRequestCreatedAndCompletedEvent } from "@nmshd/runtime"; import { ParamsDictionary, Request, Response } from "express-serve-static-core"; @@ -34,6 +42,11 @@ export default class Onboarding extends ConnectorRuntimeModule { await this.handleRegistrationQrRequest(req, res); }); + if (this.configuration.login) { + this.runtime.infrastructure.httpServer.addEndpoint(HttpMethod.Get, "/loginQR", false, async (req, res) => { + await this.handleLoginQrRequest(req, res); + }); + } } public start(): void { @@ -240,7 +253,20 @@ export default class Onboarding extends ConnectorRuntimeModule { + /* This function is responsible for creating a QR-Request for a login with enmeshed. + * To later associate the login request with a given session it needs to be passed in the query. + * This could be done with a proxy that simply ads the session id to the request and forwards it to this module. */ + private async handleLoginQrRequest(req: Request>, res: Response, number>): Promise { + const query = req.query; + if (!query.sId) { + return res.status(400).send("You need to specify the session id to later associate the login reuest with that session."); + } + const qrBytes: ArrayBuffer = (await this.createQRCode(RegistrationType.Newcommer, undefined, query.sId as string, true))[0]; + return res.send(arrayBufferToStringArray(qrBytes)).status(200); + } + + + private async createQRCode(type: RegistrationType, userId?: string, sId?: string, login?: boolean): Promise<[ArrayBuffer, string]> { const identity = (await this.runtime.transportServices.account.getIdentityInfo()).value; const sharableDisplayName = await this.getOrCreateConnectorDisplayName(identity.address, this.configuration.displayName); @@ -265,7 +291,7 @@ export default class Onboarding extends ConnectorRuntimeModule Date: Mon, 6 Mar 2023 15:34:01 +0100 Subject: [PATCH 18/34] feat: add login event handler --- src/modules/onboarding/Onboarding.ts | 84 +++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 13 deletions(-) diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index c696ec40..fd321052 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -8,13 +8,14 @@ import { ResponseJSON } from "@nmshd/content"; import { CryptoPasswordGenerator } from "@nmshd/crypto"; -import { OutgoingRequestCreatedAndCompletedEvent } from "@nmshd/runtime"; +import { LocalRequestDTO, OutgoingRequestCreatedAndCompletedEvent } from "@nmshd/runtime"; import { ParamsDictionary, Request, Response } from "express-serve-static-core"; import { DateTime } from "luxon"; import { ParsedQs } from "qs"; import { ConnectorRuntimeModule, ConnectorRuntimeModuleConfiguration } from "../../ConnectorRuntimeModule"; import { HttpMethod } from "../../infrastructure"; import { OnboardingCompletedEvent, RegistrationCompletedEvent } from "./events"; +import { LoginCompletedEvent } from "./events/LoginCompletedEvent"; import { IdentityProvider, IdentityProviderConfig, KeycloakIdentityProvider, RegistrationType, Result } from "./identityProviders"; export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration, IdentityProviderConfig {} @@ -56,13 +57,41 @@ export default class Onboarding extends ConnectorRuntimeModule { const query = req.query; let password: string | undefined; + switch (this.configuration.passwordStrategy) { case "ownPassword": { if (!query.password) { @@ -265,6 +307,22 @@ export default class Onboarding extends ConnectorRuntimeModule { + const peer = request.peer; + const relationship = await this.runtime.consumptionServices.attributes.getAttributes({ + query: { + "content.key": "userId", + "shareInfo.peer": peer + } + }); + if (relationship.isError) { + return undefined; + } + // TODO: remove as any cast + const userId = (relationship.value[0].content.value as ProprietaryStringJSON).value; + const tokens = await this.idp.login!(userId); + return { target: userId, tokens }; + } private async createQRCode(type: RegistrationType, userId?: string, sId?: string, login?: boolean): Promise<[ArrayBuffer, string]> { const identity = (await this.runtime.transportServices.account.getIdentityInfo()).value; @@ -426,17 +484,17 @@ export default class Onboarding extends ConnectorRuntimeModule Date: Mon, 6 Mar 2023 15:35:44 +0100 Subject: [PATCH 19/34] chore: remove resolved TODO --- src/modules/onboarding/Onboarding.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index fd321052..98846970 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -318,7 +318,6 @@ export default class Onboarding extends ConnectorRuntimeModule Date: Tue, 7 Mar 2023 14:53:23 +0100 Subject: [PATCH 20/34] chore: update config structure --- src/modules/onboarding/Onboarding.ts | 26 +++++++++---------- src/modules/onboarding/OnboardingConfig.ts | 17 ++++++++++++ .../IdentityProviderConfig.ts | 15 +---------- .../KeycloakIdentityProvider.ts | 4 +-- 4 files changed, 33 insertions(+), 29 deletions(-) create mode 100644 src/modules/onboarding/OnboardingConfig.ts diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index 98846970..d6d3eb30 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -16,9 +16,9 @@ import { ConnectorRuntimeModule, ConnectorRuntimeModuleConfiguration } from "../ import { HttpMethod } from "../../infrastructure"; import { OnboardingCompletedEvent, RegistrationCompletedEvent } from "./events"; import { LoginCompletedEvent } from "./events/LoginCompletedEvent"; -import { IdentityProvider, IdentityProviderConfig, KeycloakIdentityProvider, RegistrationType, Result } from "./identityProviders"; +import { OnboardingConfig } from "./OnboardingConfig"; -export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration, IdentityProviderConfig {} +export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration, KeycloakClientConfig, OnboardingConfig {} export default class Onboarding extends ConnectorRuntimeModule { private idp: IdentityProvider; @@ -26,7 +26,7 @@ export default class Onboarding extends ConnectorRuntimeModule { this.idp = new KeycloakIdentityProvider(this.configuration); - if (this.configuration.passwordStrategy === "ownPassword") { + if (this.configuration.passwordStrategy === "setByRequest") { this.passwordStore = new Map(); } @@ -63,7 +63,7 @@ export default class Onboarding extends ConnectorRuntimeModule Date: Tue, 7 Mar 2023 14:53:58 +0100 Subject: [PATCH 21/34] feat: error handling and module endpoints --- config/dev.json | 6 +- src/modules/onboarding/Onboarding.ts | 128 ++++++++++++------ .../events/OnboardingCompletedEvent.ts | 4 +- .../events/RegistrationCompletedEvent.ts | 4 +- .../identityProviders/IdentityProvider.ts | 6 +- .../KeycloakIdentityProvider.ts | 14 +- 6 files changed, 102 insertions(+), 60 deletions(-) diff --git a/config/dev.json b/config/dev.json index b7627474..c63850d4 100644 --- a/config/dev.json +++ b/config/dev.json @@ -49,9 +49,9 @@ "username": "admin", "password": "Pa55w0rd" }, - "passwordStrategy": "ownPassword", - "userIdStrategy": "custom", - "login": true + "passwordStrategy": "setByRequest", + "userIdStrategy": "setByRequest", + "authenticateUsersByEnmeshedChallenge": true } } } diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index d6d3eb30..86cd5478 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -1,3 +1,4 @@ +import { ApplicationError, Result } from "@js-soft/ts-utils"; import { CreateAttributeRequestItemJSON, ProprietaryStringJSON, @@ -9,6 +10,7 @@ import { } from "@nmshd/content"; import { CryptoPasswordGenerator } from "@nmshd/crypto"; import { LocalRequestDTO, OutgoingRequestCreatedAndCompletedEvent } from "@nmshd/runtime"; +import { QRCode } from "@nmshd/runtime/dist/useCases/common"; import { ParamsDictionary, Request, Response } from "express-serve-static-core"; import { DateTime } from "luxon"; import { ParsedQs } from "qs"; @@ -16,6 +18,7 @@ import { ConnectorRuntimeModule, ConnectorRuntimeModuleConfiguration } from "../ import { HttpMethod } from "../../infrastructure"; import { OnboardingCompletedEvent, RegistrationCompletedEvent } from "./events"; import { LoginCompletedEvent } from "./events/LoginCompletedEvent"; +import { IdentityProvider, IDPResult, KeycloakClientConfig, KeycloakIdentityProvider, RegistrationType } from "./identityProviders"; import { OnboardingConfig } from "./OnboardingConfig"; export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration, KeycloakClientConfig, OnboardingConfig {} @@ -37,15 +40,18 @@ export default class Onboarding extends ConnectorRuntimeModule { - await this.handleOnboardingQrRequest(req, res); + this.runtime.infrastructure.httpServer.addEndpoint(HttpMethod.Post, "/qrCode", false, async (req, res) => { + await this.createQRCode(req, res); }); - this.runtime.infrastructure.httpServer.addEndpoint(HttpMethod.Get, "/registrationQR", false, async (req, res) => { - await this.handleRegistrationQrRequest(req, res); + this.runtime.infrastructure.httpServer.addEndpoint(HttpMethod.Post, "/onboarding", false, async (req, res) => { + await this.handleOnboardingRequest(req, res); }); - if (this.configuration.login) { - this.runtime.infrastructure.httpServer.addEndpoint(HttpMethod.Get, "/loginQR", false, async (req, res) => { - await this.handleLoginQrRequest(req, res); + this.runtime.infrastructure.httpServer.addEndpoint(HttpMethod.Post, "/registration", false, async (req, res) => { + await this.handleRegistrationRequest(req, res); + }); + if (this.configuration.authenticateUsersByEnmeshedChallenge) { + this.runtime.infrastructure.httpServer.addEndpoint(HttpMethod.Post, "/login", false, async (req, res) => { + await this.handleLoginRequest(req, res); }); } } @@ -158,7 +164,7 @@ export default class Onboarding extends ConnectorRuntimeModule>, - res: Response, number> - ): Promise { - const query = req.query; - - const user = await this.idp.getUser(query.userId as string); - - if (query.userId && user) { - const qrBytes: ArrayBuffer = (await this.createQRCode(RegistrationType.Newcommer, query.userId as string, query.sId as string | undefined))[0]; + private async createQRCode(req: Request>, res: Response, number>) { + const body = req.body; + if (!body.data) { + res.status(400).send("Specify the truncated reference under the data field."); + return; + } + const qr = await QRCode.from(body.data, "tr"); + const qrBase64 = qr.asBase64(); + const imageBuffer = Buffer.from(qrBase64, "base64"); + res.status(200).send(arrayBufferToStringArray(imageBuffer)); + } - return res.send(arrayBufferToStringArray(qrBytes)).status(200); + private async handleOnboardingRequest(req: Request>, res: Response, number>): Promise { + const body = req.body; + const user = await this.idp.getUser(body.userId as string); + if (body.userId && user) { + const templateResult = await this.createTemplate(RegistrationType.Newcommer, body.userId as string, body.sId as string | undefined); + if (templateResult.isError) { + return res.status(templateResult.error.code as unknown as number).send(templateResult.error.message); + } + return res.status(201).send(templateResult.value); } res.status(404).send("User not found!"); } - private async handleRegistrationQrRequest( + private async handleRegistrationRequest( req: Request>, res: Response, number> ): Promise { - const query = req.query; + const body = req.body; let password: string | undefined; - switch (this.configuration.passwordStrategy) { - case "ownPassword": { - if (!query.password) { + case "setByRequest": { + if (!body.password) { return res .status(400) .send( "The module is configured in a way so that you need to pass a password, that will be used to create the account, in order to create a account with enmeshed." ); } - password = query.password as string; + password = body.password as string; } default: { // Nothing to do here for us since the password will be generated automatically when the relationship is accepted @@ -286,25 +306,33 @@ export default class Onboarding extends ConnectorRuntimeModule>, res: Response, number>): Promise { - const query = req.query; - if (!query.sId) { + private async handleLoginRequest(req: Request>, res: Response, number>): Promise { + const body = req.body; + if (!body.sId) { return res.status(400).send("You need to specify the session id to later associate the login reuest with that session."); } - const qrBytes: ArrayBuffer = (await this.createQRCode(RegistrationType.Newcommer, undefined, query.sId as string, true))[0]; - return res.send(arrayBufferToStringArray(qrBytes)).status(200); + const templateResult = await this.createTemplate(RegistrationType.Newcommer, undefined, body.sId as string, true); + + if (templateResult.isError) { + return res.status(templateResult.error.code as unknown as number).send(templateResult.error.message); + } + return res.status(201).send(templateResult.value); } private async handleEnmeshedLogin(request: LocalRequestDTO): Promise<{ target: string; tokens?: string } | undefined> { @@ -323,7 +351,7 @@ export default class Onboarding extends ConnectorRuntimeModule { + private async createTemplate(type: RegistrationType, userId?: string, sId?: string, login?: boolean): Promise> { const identity = (await this.runtime.transportServices.account.getIdentityInfo()).value; const sharableDisplayName = await this.getOrCreateConnectorDisplayName(identity.address, this.configuration.displayName); @@ -469,8 +497,12 @@ export default class Onboarding extends ConnectorRuntimeModule { diff --git a/src/modules/onboarding/events/RegistrationCompletedEvent.ts b/src/modules/onboarding/events/RegistrationCompletedEvent.ts index 68438dba..ee973f55 100644 --- a/src/modules/onboarding/events/RegistrationCompletedEvent.ts +++ b/src/modules/onboarding/events/RegistrationCompletedEvent.ts @@ -3,12 +3,14 @@ import { DataEvent } from "@js-soft/ts-utils"; interface RegistrationCompletedEventData { userId: string; sessionId?: string; + onboardingId: string; password?: string; } export interface RegistrationResult { success: boolean; - data: RegistrationCompletedEventData | undefined; + data?: RegistrationCompletedEventData; + errorMessage?: string; } export class RegistrationCompletedEvent extends DataEvent { diff --git a/src/modules/onboarding/identityProviders/IdentityProvider.ts b/src/modules/onboarding/identityProviders/IdentityProvider.ts index 2fd6af0f..5ec3d85e 100644 --- a/src/modules/onboarding/identityProviders/IdentityProvider.ts +++ b/src/modules/onboarding/identityProviders/IdentityProvider.ts @@ -2,14 +2,14 @@ import { ResponseJSON } from "@nmshd/content"; export interface IdentityProvider { initialize(): Promise; - onboard(change: ResponseJSON, userId: string): Promise; - register(change: ResponseJSON, userId: string, password: string): Promise; + onboard(change: ResponseJSON, userId: string): Promise; + register(change: ResponseJSON, userId: string, password: string): Promise; getUser(userId: string): Promise; login?(userId: string): Promise; getExistingUserInfo(userId: string, requestedData: string[]): Promise>; } -export enum Result { +export enum IDPResult { Success, Error } diff --git a/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts index f99e159f..a6129ca1 100644 --- a/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts +++ b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts @@ -4,7 +4,7 @@ import AgentKeepAlive, { HttpsAgent } from "agentkeepalive"; import AsyncRetry from "async-retry"; import axios, { AxiosInstance } from "axios"; import { KeycloakUserWithRoles } from "../KeycloakUser"; -import { IdentityProvider, Result } from "./IdentityProvider"; +import { IdentityProvider, IDPResult } from "./IdentityProvider"; import { KeycloakClientConfig } from "./IdentityProviderConfig"; // eslint-disable-next-line @typescript-eslint/no-require-imports const randExp = require("randexp"); @@ -52,18 +52,18 @@ export class KeycloakIdentityProvider implements IdentityProvider { } } - public async onboard(change: ResponseJSON, userId: string): Promise { + public async onboard(change: ResponseJSON, userId: string): Promise { const userData = getUserData(change, userId); const status = await this.updateUser(userData); if (status !== 204) { - return Result.Error; + return IDPResult.Error; } - return Result.Success; + return IDPResult.Success; } - public async register(change: ResponseJSON, userId: string, password: string): Promise { + public async register(change: ResponseJSON, userId: string, password: string): Promise { const userData = getUserData(change, userId); const status = await this.createUser({ @@ -71,9 +71,9 @@ export class KeycloakIdentityProvider implements IdentityProvider { ...{ password: password } }); if (status !== 201) { - return Result.Error; + return IDPResult.Error; } - return Result.Success; + return IDPResult.Success; } public async login(userId: string): Promise { From c69538f65dd1ac50225d4227ea285ade24ee343d Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Wed, 8 Mar 2023 08:40:56 +0100 Subject: [PATCH 22/34] chore: update config --- config/dev.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/dev.json b/config/dev.json index c63850d4..719e77f6 100644 --- a/config/dev.json +++ b/config/dev.json @@ -50,7 +50,7 @@ "password": "Pa55w0rd" }, "passwordStrategy": "setByRequest", - "userIdStrategy": "setByRequest", + "userIdStrategy": "enmeshedAddress", "authenticateUsersByEnmeshedChallenge": true } } From 368f39a902da7dc366ae231da59978b948372b38 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Wed, 8 Mar 2023 08:43:05 +0100 Subject: [PATCH 23/34] feat: implement userid strategy --- src/modules/onboarding/Onboarding.ts | 43 +++++++++++++--------- src/modules/onboarding/OnboardingConfig.ts | 1 - 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index 86cd5478..38852d65 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -99,12 +99,9 @@ export default class Onboarding extends ConnectorRuntimeModule Date: Wed, 8 Mar 2023 08:43:30 +0100 Subject: [PATCH 24/34] feat: use session store and add expire manager --- src/modules/onboarding/Onboarding.ts | 39 +++++++++-- .../onboarding/events/LoginCompletedEvent.ts | 3 +- src/modules/onboarding/utils/ExpireManager.ts | 32 +++++++++ src/modules/onboarding/utils/Queue.ts | 67 +++++++++++++++++++ 4 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 src/modules/onboarding/utils/ExpireManager.ts create mode 100644 src/modules/onboarding/utils/Queue.ts diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index 38852d65..448220a5 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -20,15 +20,20 @@ import { OnboardingCompletedEvent, RegistrationCompletedEvent } from "./events"; import { LoginCompletedEvent } from "./events/LoginCompletedEvent"; import { IdentityProvider, IDPResult, KeycloakClientConfig, KeycloakIdentityProvider, RegistrationType } from "./identityProviders"; import { OnboardingConfig } from "./OnboardingConfig"; +import { ExpireManager } from "./utils/ExpireManager"; export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration, KeycloakClientConfig, OnboardingConfig {} export default class Onboarding extends ConnectorRuntimeModule { private idp: IdentityProvider; private passwordStore?: Map; + private sessionStore: Map; + private expireManager: ExpireManager; public async init(): Promise { this.idp = new KeycloakIdentityProvider(this.configuration); + this.sessionStore = new Map(); + this.expireManager = new ExpireManager({ minutes: 5 }); if (this.configuration.passwordStrategy === "setByRequest") { this.passwordStore = new Map(); } @@ -42,16 +47,20 @@ export default class Onboarding extends ConnectorRuntimeModule { await this.createQRCode(req, res); + this.cleanupStores(); }); this.runtime.infrastructure.httpServer.addEndpoint(HttpMethod.Post, "/onboarding", false, async (req, res) => { await this.handleOnboardingRequest(req, res); + this.cleanupStores(); }); this.runtime.infrastructure.httpServer.addEndpoint(HttpMethod.Post, "/registration", false, async (req, res) => { await this.handleRegistrationRequest(req, res); + this.cleanupStores(); }); if (this.configuration.authenticateUsersByEnmeshedChallenge) { this.runtime.infrastructure.httpServer.addEndpoint(HttpMethod.Post, "/login", false, async (req, res) => { await this.handleLoginRequest(req, res); + this.cleanupStores(); }); } } @@ -60,6 +69,14 @@ export default class Onboarding extends ConnectorRuntimeModule { + this.sessionStore.delete(ref); + this.passwordStore?.delete(ref); + }); + } + private async handleOutgoingRequestCreatedAndCompleted(event: OutgoingRequestCreatedAndCompletedEvent) { const data = event.data; const responseSourceType = data.response?.source?.type; @@ -115,14 +132,17 @@ export default class Onboarding extends ConnectorRuntimeModule; + private readonly expireTime: DurationLike; + + public constructor(expireTime: DurationLike) { + this.queue = new Queue(); + this.expireTime = expireTime; + } + + public addItemToExpire(reference: string): void { + this.queue.push({ + deadline: DateTime.now().plus(this.expireTime), + reference + }); + } + + public retrieveExpiredItems(): string[] { + const deprecatedItems = []; + while (this.queue.peek() && this.queue.peek()!.deadline < DateTime.now()) { + deprecatedItems.push(this.queue.pop()!.reference); + } + return deprecatedItems; + } +} + +interface ToExpire { + deadline: DateTime; + reference: string; +} diff --git a/src/modules/onboarding/utils/Queue.ts b/src/modules/onboarding/utils/Queue.ts new file mode 100644 index 00000000..22aa0ed3 --- /dev/null +++ b/src/modules/onboarding/utils/Queue.ts @@ -0,0 +1,67 @@ +export class Queue { + private head?: Node; + private tail?: Node; + private size: number; + + public constructor() { + this.head = undefined; + this.tail = undefined; + this.size = 0; + } + + public peek(): T | undefined { + return this.head?.getValue(); + } + + public pop(): T | undefined { + const toReturn = this.head; + if (this.size === 1) { + this.head = undefined; + this.tail = undefined; + this.size = 0; + } else if (this.size > 1) { + this.head = this.head!.getNext(); + this.size -= 1; + } + return toReturn?.getValue(); + } + + public push(value: T): void { + const newNode = new Node(value); + if (this.size === 0) { + this.head = newNode; + this.tail = newNode; + this.size = 1; + } else { + this.tail!.setNext(newNode); + this.tail = newNode; + this.size += 1; + } + } +} + +class Node { + private value: T; + private next?: Node; + + public constructor(value: T) { + this.value = value; + this.next = undefined; + } + + public getValue(): T { + return this.value; + } + + public getNext(): Node | undefined { + return this.next; + } + + public setValue(value: T) { + this.value = value; + } + + public setNext(next: Node | undefined) { + this.next = next; + } +} From 8e2395d1ce7f727af1927fd5d1d382beef0842b8 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Wed, 8 Mar 2023 08:52:51 +0100 Subject: [PATCH 25/34] chore: update naming --- src/modules/onboarding/utils/ExpireManager.ts | 4 ++-- src/modules/onboarding/utils/Queue.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/onboarding/utils/ExpireManager.ts b/src/modules/onboarding/utils/ExpireManager.ts index 72357d1b..cdfe6b0a 100644 --- a/src/modules/onboarding/utils/ExpireManager.ts +++ b/src/modules/onboarding/utils/ExpireManager.ts @@ -11,7 +11,7 @@ export class ExpireManager { } public addItemToExpire(reference: string): void { - this.queue.push({ + this.queue.enqueue({ deadline: DateTime.now().plus(this.expireTime), reference }); @@ -20,7 +20,7 @@ export class ExpireManager { public retrieveExpiredItems(): string[] { const deprecatedItems = []; while (this.queue.peek() && this.queue.peek()!.deadline < DateTime.now()) { - deprecatedItems.push(this.queue.pop()!.reference); + deprecatedItems.push(this.queue.dequeue()!.reference); } return deprecatedItems; } diff --git a/src/modules/onboarding/utils/Queue.ts b/src/modules/onboarding/utils/Queue.ts index 22aa0ed3..cac4fc5f 100644 --- a/src/modules/onboarding/utils/Queue.ts +++ b/src/modules/onboarding/utils/Queue.ts @@ -13,7 +13,7 @@ export class Queue { return this.head?.getValue(); } - public pop(): T | undefined { + public dequeue(): T | undefined { const toReturn = this.head; if (this.size === 1) { this.head = undefined; @@ -26,7 +26,7 @@ export class Queue { return toReturn?.getValue(); } - public push(value: T): void { + public enqueue(value: T): void { const newNode = new Node(value); if (this.size === 0) { this.head = newNode; From f7fd034aacb104a1435b01f14e6e3e437799dbb9 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Fri, 10 Mar 2023 14:41:18 +0100 Subject: [PATCH 26/34] feat: update config names and implement store and expire manager --- config/dev.json | 7 +- src/modules/onboarding/Onboarding.ts | 123 +++++++++------------ src/modules/onboarding/OnboardingConfig.ts | 6 +- 3 files changed, 59 insertions(+), 77 deletions(-) diff --git a/config/dev.json b/config/dev.json index 719e77f6..3f41aa33 100644 --- a/config/dev.json +++ b/config/dev.json @@ -49,9 +49,10 @@ "username": "admin", "password": "Pa55w0rd" }, - "passwordStrategy": "setByRequest", - "userIdStrategy": "enmeshedAddress", - "authenticateUsersByEnmeshedChallenge": true + "passwordStrategy": "useGivenPassword", + "userIdStrategy": "useEnmeshedAddress", + "authenticateUsersByEnmeshedChallenge": true, + "templateExpiresAfterXMinutes": 5 } } } diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index 448220a5..de12650e 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -26,17 +26,13 @@ export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfigurat export default class Onboarding extends ConnectorRuntimeModule { private idp: IdentityProvider; - private passwordStore?: Map; - private sessionStore: Map; + private store: Map; private expireManager: ExpireManager; public async init(): Promise { this.idp = new KeycloakIdentityProvider(this.configuration); - this.sessionStore = new Map(); - this.expireManager = new ExpireManager({ minutes: 5 }); - if (this.configuration.passwordStrategy === "setByRequest") { - this.passwordStore = new Map(); - } + this.store = new Map(); + this.expireManager = new ExpireManager({ minutes: this.configuration.templateExpiresAfterXMinutes }); try { await this.idp.initialize(); @@ -72,8 +68,7 @@ export default class Onboarding extends ConnectorRuntimeModule { - this.sessionStore.delete(ref); - this.passwordStore?.delete(ref); + this.store.delete(ref); }); } @@ -132,9 +127,13 @@ export default class Onboarding extends ConnectorRuntimeModule>, res: Response, number>): Promise { const body = req.body; + if (!body.userId) { + return res.status(400).send("The userId property is mandatory."); + } + if (!body.sId) { + return res.status(400).send("The sId property is mandatory, this is needed to later map the emited event to a browser session."); + } const user = await this.idp.getUser(body.userId as string); - if (body.userId && user) { - const templateResult = await this.createTemplate(RegistrationType.Newcommer, body.userId as string, body.sId as string | undefined); + if (user) { + const templateResult = await this.createTemplate(RegistrationType.Onboarding, body.sId as string, body.userId as string); if (templateResult.isError) { return res.status(templateResult.error.code as unknown as number).send(templateResult.error.message); } @@ -313,7 +312,7 @@ export default class Onboarding extends ConnectorRuntimeModule> { + private async createTemplate(type: RegistrationType, sId: string, userId?: string, login?: boolean): Promise> { const identity = (await this.runtime.transportServices.account.getIdentityInfo()).value; const sharableDisplayName = await this.getOrCreateConnectorDisplayName(identity.address, this.configuration.displayName); @@ -401,25 +400,6 @@ export default class Onboarding extends ConnectorRuntimeModule Date: Fri, 10 Mar 2023 14:43:06 +0100 Subject: [PATCH 27/34] feat: allow allready connected users to onboard to existing account --- src/modules/onboarding/Onboarding.ts | 238 +++++++++++++++++++++++---- 1 file changed, 208 insertions(+), 30 deletions(-) diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index de12650e..10f249dd 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -79,34 +79,8 @@ export default class Onboarding extends ConnectorRuntimeModule { + const metadata = data.content.metadata as any; + if (data.content.items[0]["@type"] !== "AuthenticationRequestItem" || !metadata || !metadata.__createdByConnectorModule) { + // This message is not created by us + return; + } + const templateId = data.source?.reference; + if (data.content.items[0].title === "Login Request") { + if (!this.configuration.authenticateUsersByEnmeshedChallenge) { + // Message is only interesting if login is enabled + return; + } + // This should be impossible since the module only produces templates + if (!templateId) { + throw new Error("Received a message that is marked as created by the module but was not comunicated via template."); + } + // Check if store data has expired + const sessionId = this.store.get(templateId)?.sessionId; + if (!sessionId) { + this.runtime.eventBus.publish( + new LoginCompletedEvent({ + success: false, + data: undefined, + sessionId: undefined, + onboardingId: templateId, + errorMessage: `The template '${templateId}' and associated store data have expired.` + }) + ); + return; + } + const loginResult = await this.handleEnmeshedLogin(data); + if (loginResult.isError) { + if (loginResult.error.code === "error.onboarding.authentication.noAssociatedIdpUserToEnmeshedAddress") { + this.runtime.eventBus.publish( + new LoginCompletedEvent({ + success: false, + data: undefined, + sessionId: sessionId, + onboardingId: templateId, + errorMessage: loginResult.error.message + }) + ); + return; + } + throw new Error("Internal Connector error when handling enmeshed login."); + } + this.runtime.eventBus.publish( + new LoginCompletedEvent({ + success: loginResult.value.tokens ? true : false, + data: loginResult.value, + sessionId, + onboardingId: templateId + }) + ); + return; + } + if (data.content.items[0].title === "Onboarding Request") { + if (!templateId) { + return; + } + // if (data.content.) + await this.handleIDPOnboardingOfExistingEnmeshedUser(data, templateId); + } + } + + private async handleIDPOnboardingOfExistingEnmeshedUser(request: LocalRequestDTO, templateId: string) { + const peer = request.peer; + const relationship = await this.runtime.consumptionServices.attributes.getAttributes({ + query: { + "content.key": "userId", + "shareInfo.peer": peer + } + }); + if (relationship.isError) { + return; + } + const storeData = this.store.get(templateId); + if (relationship.value.length > 0) { + this.runtime.eventBus.publish( + new OnboardingCompletedEvent({ + onboardingId: templateId, + success: false, + data: { + userId: storeData?.userId ?? "", + sessionId: storeData?.sessionId + }, + errorMessage: + "The enmeshed account is allready connected to another IDP account. It is curently not supported to have more than one IDP account linked to your enmeshed account." + }) + ); + return; + } + // Check if we have the necessary data in store to onboard the user + if (!storeData?.userId) { + this.runtime.eventBus.publish( + new OnboardingCompletedEvent({ + onboardingId: templateId, + success: false, + data: undefined, + errorMessage: "The onboarding template and coresponding store data have expired please request a new one." + }) + ); + return; + } + const user = await this.idp.getUser(storeData.userId); + if (!user) { + this.runtime.eventBus.publish( + new OnboardingCompletedEvent({ + onboardingId: templateId, + success: false, + data: { + userId: storeData.userId, + sessionId: storeData.sessionId + }, + errorMessage: "The IDP userId saved in store could not be found." + }) + ); + return; + } + const onboardingResponse = await this.idp.onboard(request.response!.content, storeData.userId, request.peer); + + if (onboardingResponse === IDPResult.Error) { + this.runtime.eventBus.publish( + new OnboardingCompletedEvent({ + onboardingId: templateId, + success: false, + data: { + userId: storeData.userId, + sessionId: storeData.sessionId + }, + errorMessage: "Unable to update the IDP user." + }) + ); + } + + this.runtime.eventBus.publish( + new OnboardingCompletedEvent({ + onboardingId: templateId, + success: true, + data: { + userId: storeData.userId, + sessionId: storeData.sessionId + } + }) + ); + // After onboarding the user we now need to save the userId in the enmeshed wallet + const identityResponse = await this.runtime.transportServices.account.getIdentityInfo(); + if (identityResponse.isError) { + throw new Error(identityResponse.error.message); + } + const outgoingRequestResponse = await this.runtime.consumptionServices.outgoingRequests.create({ + content: { + items: [ + { + "@type": "CreateAttributeRequestItem", + mustBeAccepted: true, + attribute: { + "@type": "RelationshipAttribute", + owner: identityResponse.value.address, + key: "userId", + value: { + "@type": "ProprietaryString", + title: `${this.configuration.displayName}.userId`, + value: storeData.userId + }, + isTechnical: false, + confidentiality: RelationshipAttributeConfidentiality.Public + } + } + ] + }, + peer: peer + }); + if (outgoingRequestResponse.isError) { + this.logger.error(outgoingRequestResponse.error); + return; + } + const requestContent = outgoingRequestResponse.value.content; + + const messageResponse = await this.runtime.transportServices.messages.sendMessage({ + recipients: [peer], + content: requestContent + }); + if (messageResponse.isError) { + this.logger.error(messageResponse.error); + } + } + private async createQRCode(req: Request>, res: Response, number>) { const body = req.body; if (!body.data) { @@ -519,7 +681,7 @@ export default class Onboarding extends ConnectorRuntimeModule Date: Fri, 10 Mar 2023 14:46:09 +0100 Subject: [PATCH 28/34] feat: actually create keycloak attribute, better error messages, send userId to wallet on success --- src/modules/onboarding/Onboarding.ts | 189 ++++++++++++++---- .../onboarding/events/LoginCompletedEvent.ts | 2 + .../events/OnboardingCompletedEvent.ts | 2 +- .../events/RegistrationCompletedEvent.ts | 2 +- .../identityProviders/IdentityProvider.ts | 4 +- .../KeycloakIdentityProvider.ts | 18 +- 6 files changed, 164 insertions(+), 53 deletions(-) diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index 10f249dd..0d39ffc9 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -1,13 +1,5 @@ import { ApplicationError, Result } from "@js-soft/ts-utils"; -import { - CreateAttributeRequestItemJSON, - ProprietaryStringJSON, - RelationshipAttributeConfidentiality, - RequestItemGroupJSON, - RequestItemJSONDerivations, - RequestJSON, - ResponseJSON -} from "@nmshd/content"; +import { ProprietaryStringJSON, RelationshipAttributeConfidentiality, RequestItemGroupJSON, RequestItemJSONDerivations, RequestJSON, ResponseJSON } from "@nmshd/content"; import { CryptoPasswordGenerator } from "@nmshd/crypto"; import { LocalRequestDTO, OutgoingRequestCreatedAndCompletedEvent } from "@nmshd/runtime"; import { QRCode } from "@nmshd/runtime/dist/useCases/common"; @@ -22,6 +14,12 @@ import { IdentityProvider, IDPResult, KeycloakClientConfig, KeycloakIdentityProv import { OnboardingConfig } from "./OnboardingConfig"; import { ExpireManager } from "./utils/ExpireManager"; +/* TODO: The Module momentarily uses a wallet attribute to determine if a given enmeshed account is connected to a IDP account. + * This should be the other way around in the future so that the Login still works if the user denies the creation of the userId attribute. + * If we find a way to search a IDP user by custom attribute this can be done. Otherwise this is a design flaw we are unable to fix, + * since it is to unperformant to actually traverse all users to search for a enmeshed address. + */ + export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration, KeycloakClientConfig, OnboardingConfig {} export default class Onboarding extends ConnectorRuntimeModule { @@ -108,6 +106,30 @@ export default class Onboarding extends ConnectorRuntimeModule { + private async handleEnmeshedLogin(request: LocalRequestDTO): Promise> { const peer = request.peer; const relationship = await this.runtime.consumptionServices.attributes.getAttributes({ query: { @@ -537,11 +638,19 @@ export default class Onboarding extends ConnectorRuntimeModule> { @@ -747,7 +856,7 @@ export default class Onboarding extends ConnectorRuntimeModule { diff --git a/src/modules/onboarding/events/RegistrationCompletedEvent.ts b/src/modules/onboarding/events/RegistrationCompletedEvent.ts index ee973f55..0009e86d 100644 --- a/src/modules/onboarding/events/RegistrationCompletedEvent.ts +++ b/src/modules/onboarding/events/RegistrationCompletedEvent.ts @@ -3,7 +3,6 @@ import { DataEvent } from "@js-soft/ts-utils"; interface RegistrationCompletedEventData { userId: string; sessionId?: string; - onboardingId: string; password?: string; } @@ -11,6 +10,7 @@ export interface RegistrationResult { success: boolean; data?: RegistrationCompletedEventData; errorMessage?: string; + onboardingId: string; } export class RegistrationCompletedEvent extends DataEvent { diff --git a/src/modules/onboarding/identityProviders/IdentityProvider.ts b/src/modules/onboarding/identityProviders/IdentityProvider.ts index 5ec3d85e..9a734693 100644 --- a/src/modules/onboarding/identityProviders/IdentityProvider.ts +++ b/src/modules/onboarding/identityProviders/IdentityProvider.ts @@ -2,8 +2,8 @@ import { ResponseJSON } from "@nmshd/content"; export interface IdentityProvider { initialize(): Promise; - onboard(change: ResponseJSON, userId: string): Promise; - register(change: ResponseJSON, userId: string, password: string): Promise; + onboard(change: ResponseJSON, userId: string, enmeshedAddress: string): Promise; + register(change: ResponseJSON, userId: string, password: string, enmeshedAddress: string): Promise; getUser(userId: string): Promise; login?(userId: string): Promise; getExistingUserInfo(userId: string, requestedData: string[]): Promise>; diff --git a/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts index a6129ca1..2fb083c0 100644 --- a/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts +++ b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts @@ -52,8 +52,8 @@ export class KeycloakIdentityProvider implements IdentityProvider { } } - public async onboard(change: ResponseJSON, userId: string): Promise { - const userData = getUserData(change, userId); + public async onboard(change: ResponseJSON, userId: string, enmeshedAddress: string): Promise { + const userData = getUserData(change, userId, enmeshedAddress); const status = await this.updateUser(userData); @@ -63,8 +63,8 @@ export class KeycloakIdentityProvider implements IdentityProvider { return IDPResult.Success; } - public async register(change: ResponseJSON, userId: string, password: string): Promise { - const userData = getUserData(change, userId); + public async register(change: ResponseJSON, userId: string, password: string, enmeshedAddress: string): Promise { + const userData = getUserData(change, userId, enmeshedAddress); const status = await this.createUser({ ...userData, @@ -527,7 +527,8 @@ export class KeycloakIdentityProvider implements IdentityProvider { function getUserData( request: ResponseJSON, - userId: string + userId: string, + enmeshedAddress: string ): { userName: string; attributes?: any; @@ -537,7 +538,9 @@ function getUserData( } { const retValue = { userName: userId, - attributes: {}, + attributes: { + enmeshedAddress: enmeshedAddress + }, firstName: undefined, lastName: undefined, email: undefined @@ -554,9 +557,6 @@ function getUserData( if (item["@type"] === "ReadAttributeAcceptResponseItem" || item["@type"] === "ProposeAttributeAcceptResponseItem") { const el: any = (item as any).attribute; if (el?.value) { - if (!attr.enmeshedAddress) { - Object.assign(attr, { enmeshedAddress: el.owner }); - } if (normalKeycloakAttributes.includes(el.value["@type"])) { switch (el.value["@type"]) { case "Surname": From 4dd2e2c04859d0dbff5055a7ea46acdaf84bdc01 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Fri, 10 Mar 2023 15:00:52 +0100 Subject: [PATCH 29/34] chore: udpate error code --- src/modules/onboarding/Onboarding.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index 0d39ffc9..15fcede7 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -785,7 +785,7 @@ export default class Onboarding extends ConnectorRuntimeModule Date: Mon, 13 Mar 2023 10:20:19 +0100 Subject: [PATCH 30/34] feat: shortest possible enmeshed address --- config/dev.json | 2 +- src/modules/onboarding/Onboarding.ts | 20 +++++++++++++++++--- src/modules/onboarding/OnboardingConfig.ts | 2 +- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/config/dev.json b/config/dev.json index 3f41aa33..88edfb04 100644 --- a/config/dev.json +++ b/config/dev.json @@ -50,7 +50,7 @@ "password": "Pa55w0rd" }, "passwordStrategy": "useGivenPassword", - "userIdStrategy": "useEnmeshedAddress", + "userIdStrategy": "useShortestPossibleEnmeshedAddress", "authenticateUsersByEnmeshedChallenge": true, "templateExpiresAfterXMinutes": 5 } diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index 15fcede7..2b27ed25 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -20,7 +20,7 @@ import { ExpireManager } from "./utils/ExpireManager"; * since it is to unperformant to actually traverse all users to search for a enmeshed address. */ -export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration, KeycloakClientConfig, OnboardingConfig {} +export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration, KeycloakClientConfig, OnboardingConfig { } export default class Onboarding extends ConnectorRuntimeModule { private idp: IdentityProvider; @@ -139,7 +139,7 @@ export default class Onboarding extends ConnectorRuntimeModule { + const addressLength = enmeshedAddress.length; + const shortestAddress = enmeshedAddress.substring(addressLength - length); + const user = await this.idp.getUser(shortestAddress); + if (user) { + return await this.getShortestPossibleEnmeshedAddress(enmeshedAddress, length + 1); + } + return shortestAddress; + } + private async handleIDPOnboardingOfExistingEnmeshedUser(request: LocalRequestDTO, templateId: string) { const peer = request.peer; const relationship = await this.runtime.consumptionServices.attributes.getAttributes({ @@ -840,7 +854,7 @@ export default class Onboarding extends ConnectorRuntimeModule Date: Mon, 13 Mar 2023 10:34:10 +0100 Subject: [PATCH 31/34] fix: reject relationship if an error occured --- src/modules/onboarding/Onboarding.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index 2b27ed25..304edb85 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -118,6 +118,7 @@ export default class Onboarding extends ConnectorRuntimeModule Date: Mon, 13 Mar 2023 10:51:29 +0100 Subject: [PATCH 32/34] chore: remove dependencies --- package-lock.json | 3 +++ package.json | 2 -- src/modules/onboarding/Onboarding.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f345248a..5227f986 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@nmshd/runtime": "2.4.4", "agentkeepalive": "4.3.0", "amqplib": "^0.10.3", + "async-retry": "^1.3.3", "axios": "^1.3.4", "compression": "1.7.4", "cors": "2.8.5", @@ -12610,6 +12611,7 @@ "@types/yamljs": "^0.2.31", "agentkeepalive": "4.3.0", "amqplib": "^0.10.3", + "async-retry": "^1.3.3", "axios": "^1.3.4", "compression": "1.7.4", "cors": "2.8.5", @@ -12627,6 +12629,7 @@ "npm-run-all": "^4.1.5", "on-headers": "1.0.2", "prettier": "^2.8.4", + "randexp": "^0.5.3", "rapidoc": "9.3.4", "reflect-metadata": "0.1.13", "swagger-ui-express": "4.6.2", diff --git a/package.json b/package.json index 729ae027..07e3a622 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,6 @@ "@js-soft/docdb-access-mongo": "1.1.3", "@js-soft/node-logger": "1.0.3", "@js-soft/ts-utils": "^2.3.1", - "@nmshd/content": "*", - "@nmshd/crypto": "2.0.3", "async-retry": "^1.3.3", "@nmshd/runtime": "2.4.4", "agentkeepalive": "4.3.0", diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index 304edb85..2a9abbe4 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -20,7 +20,7 @@ import { ExpireManager } from "./utils/ExpireManager"; * since it is to unperformant to actually traverse all users to search for a enmeshed address. */ -export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration, KeycloakClientConfig, OnboardingConfig { } +export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration, KeycloakClientConfig, OnboardingConfig {} export default class Onboarding extends ConnectorRuntimeModule { private idp: IdentityProvider; From 4e53671f175dc0cffdbf11a710470ffcfd397ca1 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Mon, 27 Mar 2023 14:31:58 +0200 Subject: [PATCH 33/34] chore: adjust to PR feedback --- .dev/docker-compose.debug.yml | 4 +- .dev/keycloak.conf | 2 - config/default.json | 2 +- src/modules/onboarding/KeycloakUser.ts | 4 +- src/modules/onboarding/Onboarding.ts | 287 +++++++++--------- .../onboarding/events/LoginCompletedEvent.ts | 2 +- .../identityProviders/IdentityProvider.ts | 14 +- .../KeycloakIdentityProvider.ts | 19 +- 8 files changed, 161 insertions(+), 173 deletions(-) diff --git a/.dev/docker-compose.debug.yml b/.dev/docker-compose.debug.yml index 770d4c1d..3d6e16bc 100644 --- a/.dev/docker-compose.debug.yml +++ b/.dev/docker-compose.debug.yml @@ -25,7 +25,7 @@ services: stdin_open: true tty: true - keycloaky: + keycloak: container_name: keycloaky image: quay.io/keycloak/keycloak:latest environment: @@ -39,7 +39,7 @@ services: depends_on: - postgresy - postgresy: + postgres: container_name: postgresy image: postgres volumes: diff --git a/.dev/keycloak.conf b/.dev/keycloak.conf index 04e4ec6a..fac57e7d 100644 --- a/.dev/keycloak.conf +++ b/.dev/keycloak.conf @@ -1,5 +1,3 @@ -# Basic settings for running in production. Change accordingly before deploying the server. - # Database db=postgres db-username=keycloak diff --git a/config/default.json b/config/default.json index 7aaddce4..a3ab468a 100644 --- a/config/default.json +++ b/config/default.json @@ -100,7 +100,7 @@ "displayName": "AMQP Publisher", "location": "amqpPublisher/AMQPPublisherModule" }, - "idpOnboarding": { + "onboarding": { "enabled": false, "displayName": "Onboarding", "location": "onboarding/Onboarding", diff --git a/src/modules/onboarding/KeycloakUser.ts b/src/modules/onboarding/KeycloakUser.ts index 047be037..a3d8d490 100644 --- a/src/modules/onboarding/KeycloakUser.ts +++ b/src/modules/onboarding/KeycloakUser.ts @@ -7,8 +7,8 @@ export interface KeycloakUser extends Record { emailVerified: boolean; email: string; attributes?: UserAttributes; - disableableCredentialTypes: unknown[]; - requiredActions: unknown[]; + disableableCredentialTypes?: string[]; + requiredActions?: string[]; notBefore: number; access: UserAccess; } diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index 2a9abbe4..683de4eb 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -10,7 +10,7 @@ import { ConnectorRuntimeModule, ConnectorRuntimeModuleConfiguration } from "../ import { HttpMethod } from "../../infrastructure"; import { OnboardingCompletedEvent, RegistrationCompletedEvent } from "./events"; import { LoginCompletedEvent } from "./events/LoginCompletedEvent"; -import { IdentityProvider, IDPResult, KeycloakClientConfig, KeycloakIdentityProvider, RegistrationType } from "./identityProviders"; +import { IdentityProviderOnboardingAdapter, KeycloakClientConfig, KeycloakIdentityProvider, RegistrationType } from "./identityProviders"; import { OnboardingConfig } from "./OnboardingConfig"; import { ExpireManager } from "./utils/ExpireManager"; @@ -23,7 +23,7 @@ import { ExpireManager } from "./utils/ExpireManager"; export interface OnboardingModuleConfig extends ConnectorRuntimeModuleConfiguration, KeycloakClientConfig, OnboardingConfig {} export default class Onboarding extends ConnectorRuntimeModule { - private idp: IdentityProvider; + private idp: IdentityProviderOnboardingAdapter; private store: Map; private expireManager: ExpireManager; @@ -187,76 +187,10 @@ export default class Onboarding extends ConnectorRuntimeModule> { + private async handleEnmeshedLogin(request: LocalRequestDTO): Promise> { const peer = request.peer; const relationship = await this.runtime.consumptionServices.attributes.getAttributes({ query: { @@ -666,7 +659,7 @@ export default class Onboarding extends ConnectorRuntimeModule { diff --git a/src/modules/onboarding/identityProviders/IdentityProvider.ts b/src/modules/onboarding/identityProviders/IdentityProvider.ts index 9a734693..15bdca18 100644 --- a/src/modules/onboarding/identityProviders/IdentityProvider.ts +++ b/src/modules/onboarding/identityProviders/IdentityProvider.ts @@ -1,15 +1,11 @@ +import { Result } from "@js-soft/ts-utils"; import { ResponseJSON } from "@nmshd/content"; -export interface IdentityProvider { +export interface IdentityProviderOnboardingAdapter { initialize(): Promise; - onboard(change: ResponseJSON, userId: string, enmeshedAddress: string): Promise; - register(change: ResponseJSON, userId: string, password: string, enmeshedAddress: string): Promise; + onboardExistingUserForRelationshipRequest(change: ResponseJSON, userId: string, enmeshedAddress: string): Promise>; + registerNewUserForRelationshipRequest(change: ResponseJSON, userId: string, password: string, enmeshedAddress: string): Promise>; getUser(userId: string): Promise; - login?(userId: string): Promise; + authenticateUserAndReturnSessionCredentials?(userId: string): Promise; getExistingUserInfo(userId: string, requestedData: string[]): Promise>; } - -export enum IDPResult { - Success, - Error -} diff --git a/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts index 2fb083c0..7cc3d4ab 100644 --- a/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts +++ b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import { ApplicationError, Result } from "@js-soft/ts-utils"; import { ResponseItemGroupJSON, ResponseJSON } from "@nmshd/content"; import AgentKeepAlive, { HttpsAgent } from "agentkeepalive"; import AsyncRetry from "async-retry"; import axios, { AxiosInstance } from "axios"; import { KeycloakUserWithRoles } from "../KeycloakUser"; -import { IdentityProvider, IDPResult } from "./IdentityProvider"; +import { IdentityProviderOnboardingAdapter } from "./IdentityProvider"; import { KeycloakClientConfig } from "./IdentityProviderConfig"; // eslint-disable-next-line @typescript-eslint/no-require-imports const randExp = require("randexp"); @@ -14,7 +15,7 @@ export enum RegistrationType { Onboarding = "Onboarding" } -export class KeycloakIdentityProvider implements IdentityProvider { +export class KeycloakIdentityProvider implements IdentityProviderOnboardingAdapter { private readonly axios: AxiosInstance; public constructor(private readonly config: KeycloakClientConfig) { this.axios = axios.create({ @@ -52,18 +53,18 @@ export class KeycloakIdentityProvider implements IdentityProvider { } } - public async onboard(change: ResponseJSON, userId: string, enmeshedAddress: string): Promise { + public async onboardExistingUserForRelationshipRequest(change: ResponseJSON, userId: string, enmeshedAddress: string): Promise> { const userData = getUserData(change, userId, enmeshedAddress); const status = await this.updateUser(userData); if (status !== 204) { - return IDPResult.Error; + return Result.fail(new ApplicationError("error.onboarding.idpError", "There was an error updating the idp user")); } - return IDPResult.Success; + return Result.ok(undefined); } - public async register(change: ResponseJSON, userId: string, password: string, enmeshedAddress: string): Promise { + public async registerNewUserForRelationshipRequest(change: ResponseJSON, userId: string, password: string, enmeshedAddress: string): Promise> { const userData = getUserData(change, userId, enmeshedAddress); const status = await this.createUser({ @@ -71,12 +72,12 @@ export class KeycloakIdentityProvider implements IdentityProvider { ...{ password: password } }); if (status !== 201) { - return IDPResult.Error; + return Result.fail(new ApplicationError("error.onboarding.idpError", "There was an error creating the idp user")); } - return IDPResult.Success; + return Result.ok(undefined); } - public async login(userId: string): Promise { + public async authenticateUserAndReturnSessionCredentials(userId: string): Promise { const user = await this.getUser(userId); if (!user) { From 44ec674c32f1e95135f3fd793b3b2bc1d68441b5 Mon Sep 17 00:00:00 2001 From: Korbinian Flietel Date: Mon, 27 Mar 2023 14:49:59 +0200 Subject: [PATCH 34/34] chore: adjust to PR feedback --- src/modules/onboarding/Onboarding.ts | 4 ++-- ...s => IdentityProviderOnboardingAdapter.ts} | 0 .../KeycloakIdentityProvider.ts | 24 +++++++++---------- .../onboarding/identityProviders/index.ts | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) rename src/modules/onboarding/identityProviders/{IdentityProvider.ts => IdentityProviderOnboardingAdapter.ts} (100%) diff --git a/src/modules/onboarding/Onboarding.ts b/src/modules/onboarding/Onboarding.ts index 683de4eb..81bb20bd 100644 --- a/src/modules/onboarding/Onboarding.ts +++ b/src/modules/onboarding/Onboarding.ts @@ -10,7 +10,7 @@ import { ConnectorRuntimeModule, ConnectorRuntimeModuleConfiguration } from "../ import { HttpMethod } from "../../infrastructure"; import { OnboardingCompletedEvent, RegistrationCompletedEvent } from "./events"; import { LoginCompletedEvent } from "./events/LoginCompletedEvent"; -import { IdentityProviderOnboardingAdapter, KeycloakClientConfig, KeycloakIdentityProvider, RegistrationType } from "./identityProviders"; +import { IdentityProviderOnboardingAdapter, KeycloakClientConfig, KeycloakOnboardingAdapter, RegistrationType } from "./identityProviders"; import { OnboardingConfig } from "./OnboardingConfig"; import { ExpireManager } from "./utils/ExpireManager"; @@ -28,7 +28,7 @@ export default class Onboarding extends ConnectorRuntimeModule { - this.idp = new KeycloakIdentityProvider(this.configuration); + this.idp = new KeycloakOnboardingAdapter(this.configuration); this.store = new Map(); this.expireManager = new ExpireManager({ minutes: this.configuration.templateExpiresAfterXMinutes }); diff --git a/src/modules/onboarding/identityProviders/IdentityProvider.ts b/src/modules/onboarding/identityProviders/IdentityProviderOnboardingAdapter.ts similarity index 100% rename from src/modules/onboarding/identityProviders/IdentityProvider.ts rename to src/modules/onboarding/identityProviders/IdentityProviderOnboardingAdapter.ts diff --git a/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts index 7cc3d4ab..fd2e1bed 100644 --- a/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts +++ b/src/modules/onboarding/identityProviders/KeycloakIdentityProvider.ts @@ -5,17 +5,17 @@ import AgentKeepAlive, { HttpsAgent } from "agentkeepalive"; import AsyncRetry from "async-retry"; import axios, { AxiosInstance } from "axios"; import { KeycloakUserWithRoles } from "../KeycloakUser"; -import { IdentityProviderOnboardingAdapter } from "./IdentityProvider"; import { KeycloakClientConfig } from "./IdentityProviderConfig"; +import { IdentityProviderOnboardingAdapter } from "./IdentityProviderOnboardingAdapter"; // eslint-disable-next-line @typescript-eslint/no-require-imports const randExp = require("randexp"); export enum RegistrationType { - Newcommer = "Newcommer", + Newcommer = "Newcomer", Onboarding = "Onboarding" } -export class KeycloakIdentityProvider implements IdentityProviderOnboardingAdapter { +export class KeycloakOnboardingAdapter implements IdentityProviderOnboardingAdapter { private readonly axios: AxiosInstance; public constructor(private readonly config: KeycloakClientConfig) { this.axios = axios.create({ @@ -169,7 +169,7 @@ export class KeycloakIdentityProvider implements IdentityProviderOnboardingAdapt }, { headers: { - authorization: `bearer ${adminToken}`, + authorization: `Bearer ${adminToken}`, "content-type": "application/json" } } @@ -203,7 +203,7 @@ export class KeycloakIdentityProvider implements IdentityProviderOnboardingAdapt }, { headers: { - authorization: `bearer ${adminToken}`, + authorization: `Bearer ${adminToken}`, "Content-Type": "application/json" } } @@ -242,7 +242,7 @@ export class KeycloakIdentityProvider implements IdentityProviderOnboardingAdapt }, { headers: { - authorization: `bearer ${token}`, + authorization: `Bearer ${token}`, "Content-Type": "application/json" } } @@ -264,7 +264,7 @@ export class KeycloakIdentityProvider implements IdentityProviderOnboardingAdapt await this.axios.post(`/admin/realms/${this.config.realm}/users/${user.data[0].id}/role-mappings/clients/${realmManagementClient.id}`, roles.data, { headers: { - authorization: `bearer ${token}`, + authorization: `Bearer ${token}`, "Content-Type": "application/json" } }); @@ -317,7 +317,7 @@ export class KeycloakIdentityProvider implements IdentityProviderOnboardingAdapt }, { headers: { - authorization: `bearer ${token}`, + authorization: `Bearer ${token}`, "Content-Type": "application/json" } } @@ -404,7 +404,7 @@ export class KeycloakIdentityProvider implements IdentityProviderOnboardingAdapt }, { headers: { - authorization: `bearer ${token}`, + authorization: `Bearer ${token}`, "Content-Type": "application/json" } } @@ -431,7 +431,7 @@ export class KeycloakIdentityProvider implements IdentityProviderOnboardingAdapt }, { headers: { - authorization: `bearer ${token}`, + authorization: `Bearer ${token}`, "Content-Type": "application/json" } } @@ -475,7 +475,7 @@ export class KeycloakIdentityProvider implements IdentityProviderOnboardingAdapt }, { headers: { - authorization: `bearer ${token}`, + authorization: `Bearer ${token}`, "Content-Type": "application/json" } } @@ -501,7 +501,7 @@ export class KeycloakIdentityProvider implements IdentityProviderOnboardingAdapt }, { headers: { - authorization: `bearer ${token}`, + authorization: `Bearer ${token}`, "Content-Type": "application/json" } } diff --git a/src/modules/onboarding/identityProviders/index.ts b/src/modules/onboarding/identityProviders/index.ts index 9e19e12a..8da167e0 100644 --- a/src/modules/onboarding/identityProviders/index.ts +++ b/src/modules/onboarding/identityProviders/index.ts @@ -1,3 +1,3 @@ -export * from "./IdentityProvider"; export * from "./IdentityProviderConfig"; +export * from "./IdentityProviderOnboardingAdapter"; export * from "./KeycloakIdentityProvider";