From f1abb3e8d2e05aa18008da176753b240528e95e6 Mon Sep 17 00:00:00 2001 From: Peter Somogyvari Date: Sat, 10 Apr 2021 14:43:37 -0700 Subject: [PATCH] feat(test-tooling): add keycloak container A utility class that can be used to easily pull up a working Keycloak deployment in a container during integratin tests. Can help when end to end testing authentication specific code and also for creating demos/examples for documentation purposes. Signed-off-by: Peter Somogyvari --- .../cactus-test-tooling/package-lock.json | 231 ++++++++++++ packages/cactus-test-tooling/package.json | 2 + .../typescript/keycloak/keycloak-container.ts | 355 ++++++++++++++++++ .../src/main/typescript/public-api.ts | 8 + 4 files changed, 596 insertions(+) create mode 100644 packages/cactus-test-tooling/src/main/typescript/keycloak/keycloak-container.ts diff --git a/packages/cactus-test-tooling/package-lock.json b/packages/cactus-test-tooling/package-lock.json index bb796614f7..9a7b256bfb 100644 --- a/packages/cactus-test-tooling/package-lock.json +++ b/packages/cactus-test-tooling/package-lock.json @@ -765,6 +765,11 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=" }, + "camelize": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.0.tgz", + "integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=" + }, "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -954,6 +959,16 @@ "sha.js": "^2.4.8" } }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, "crypto-browserify": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", @@ -1025,6 +1040,14 @@ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" }, + "default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "requires": { + "execa": "^5.0.0" + } + }, "defer-to-connect": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", @@ -1433,6 +1456,34 @@ "safe-buffer": "^5.1.1" } }, + "execa": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz", + "integrity": "sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ==", + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "dependencies": { + "get-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.0.tgz", + "integrity": "sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==" + }, + "is-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", + "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==" + } + } + }, "express": { "version": "4.17.1", "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", @@ -1814,6 +1865,11 @@ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, + "filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha1-mzERErxsYSehbgFsbF1/GeCAXFs=" + }, "finalhandler": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", @@ -2139,6 +2195,11 @@ "sshpk": "^1.7.0" } }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==" + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -2194,11 +2255,27 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" }, + "internal-ip": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-6.2.0.tgz", + "integrity": "sha512-D8WGsR6yDt8uq7vDMu7mjcR+yRMm3dW8yufyChmszWRjcSHuxLBkR3GdS2HZAjodsaGuCvXeEJpueisXJULghg==", + "requires": { + "default-gateway": "^6.0.0", + "ipaddr.js": "^1.9.1", + "is-ip": "^3.1.0", + "p-event": "^4.2.0" + } + }, "invert-kv": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=" }, + "ip-regex": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.3.0.tgz", + "integrity": "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==" + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2245,6 +2322,14 @@ "resolved": "https://registry.npmjs.org/is-hex-prefixed/-/is-hex-prefixed-1.0.0.tgz", "integrity": "sha1-fY035q135dEnFIkTxXPggtd39VQ=" }, + "is-ip": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-3.1.0.tgz", + "integrity": "sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==", + "requires": { + "ip-regex": "^4.0.0" + } + }, "is-negative-zero": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz", @@ -2322,6 +2407,11 @@ "punycode": "2.x.x" } }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" + }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -2346,6 +2436,11 @@ "topo": "3.x.x" } }, + "js-sha256": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", + "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==" + }, "js-sha3": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.7.0.tgz", @@ -2419,6 +2514,47 @@ "node-gyp-build": "^4.2.0" } }, + "keycloak-admin": { + "version": "1.14.11", + "resolved": "https://registry.npmjs.org/keycloak-admin/-/keycloak-admin-1.14.11.tgz", + "integrity": "sha512-s0NNLdJ27oAx52pXsvJgm8O/KDb0dbPsnbc+f4uTaz/Gzh6QN6GJPCgAYJEZj/Re+oOm+OVRHTx8bhhlrom5hA==", + "requires": { + "axios": "^0.21.0", + "camelize": "^1.0.0", + "keycloak-js": "^11.0.3", + "lodash": "^4.17.20", + "query-string": "^6.13.7", + "url-join": "^4.0.0", + "url-template": "^2.0.8" + }, + "dependencies": { + "query-string": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.14.1.tgz", + "integrity": "sha512-XDxAeVmpfu1/6IjyT/gXHOl+S0vQ9owggJ30hhWKdHAsNPOcasn5o9BW0eejZqL2e4vMjhAxoW3jVHcD6mbcYw==", + "requires": { + "decode-uri-component": "^0.2.0", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + } + }, + "strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=" + } + } + }, + "keycloak-js": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-11.0.3.tgz", + "integrity": "sha512-e2OVyCiru25UhJz3aPj5irf//+vJzvAhHdcsCIWAcvF8Te22iUoZqEdNFji8D3zNzDehX4VpuIJwQOYCj6rqTA==", + "requires": { + "base64-js": "1.3.1", + "js-sha256": "0.9.0" + } + }, "keyv": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", @@ -2443,6 +2579,11 @@ "invert-kv": "^1.0.0" } }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -2496,6 +2637,11 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + }, "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -2528,6 +2674,11 @@ "mime-db": "1.44.0" } }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" + }, "mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", @@ -2785,6 +2936,14 @@ "npm-normalize-package-bin": "^1.0.1" } }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "requires": { + "path-key": "^3.0.0" + } + }, "npmlog": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", @@ -2872,6 +3031,14 @@ "wrappy": "1" } }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "requires": { + "mimic-fn": "^2.1.0" + } + }, "optjs": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/optjs/-/optjs-3.2.2.tgz", @@ -2909,6 +3076,24 @@ "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==" }, + "p-event": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-4.2.0.tgz", + "integrity": "sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==", + "requires": { + "p-timeout": "^3.1.0" + }, + "dependencies": { + "p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "requires": { + "p-finally": "^1.0.0" + } + } + } + }, "p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -2958,6 +3143,11 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -3347,6 +3537,19 @@ "safe-buffer": "^5.0.1" } }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, "shell-escape": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/shell-escape/-/shell-escape-0.2.0.tgz", @@ -3382,6 +3585,11 @@ "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", "integrity": "sha1-bIOv82kvphJW4M0ZfgXp3hV2kaY=" }, + "split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==" + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -3485,6 +3693,11 @@ "ansi-regex": "^2.0.0" } }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==" + }, "strip-hex-prefix": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-hex-prefix/-/strip-hex-prefix-1.0.0.tgz", @@ -3758,6 +3971,11 @@ } } }, + "url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" + }, "url-parse-lax": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", @@ -3771,6 +3989,11 @@ "resolved": "https://registry.npmjs.org/url-set-query/-/url-set-query-1.0.0.tgz", "integrity": "sha1-AW6M/Xwg7gXK/neV6JK9BwL6ozk=" }, + "url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha1-/FZaPMy/93MMd19WQflVV5FDnyE=" + }, "url-to-options": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/url-to-options/-/url-to-options-1.0.1.tgz", @@ -5064,6 +5287,14 @@ } } }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "requires": { + "isexe": "^2.0.0" + } + }, "which-typed-array": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.4.tgz", diff --git a/packages/cactus-test-tooling/package.json b/packages/cactus-test-tooling/package.json index ff3157c265..e094b9aaa2 100644 --- a/packages/cactus-test-tooling/package.json +++ b/packages/cactus-test-tooling/package.json @@ -88,8 +88,10 @@ "fabric-client": "1.4.14", "fabric-network": "1.4.14", "fs-extra": "9.0.0", + "internal-ip": "6.2.0", "is-port-reachable": "3.0.0", "joi": "14.3.1", + "keycloak-admin": "1.14.11", "node-ssh": "11.1.1", "p-retry": "4.4.0", "tar-stream": "2.1.2", diff --git a/packages/cactus-test-tooling/src/main/typescript/keycloak/keycloak-container.ts b/packages/cactus-test-tooling/src/main/typescript/keycloak/keycloak-container.ts new file mode 100644 index 0000000000..42d15725b1 --- /dev/null +++ b/packages/cactus-test-tooling/src/main/typescript/keycloak/keycloak-container.ts @@ -0,0 +1,355 @@ +import { EventEmitter } from "events"; +import Docker, { Container } from "dockerode"; +import { v4 as internalIpV4 } from "internal-ip"; +import { v4 as uuidv4 } from "uuid"; +import KcAdminClient from "keycloak-admin"; +import UserRepresentation from "keycloak-admin/lib/defs/userRepresentation"; + +import { + Logger, + Checks, + LogLevelDesc, + LoggerProvider, +} from "@hyperledger/cactus-common"; + +import { Containers } from "../common/containers"; +import RealmRepresentation from "keycloak-admin/lib/defs/realmRepresentation"; + +export interface IKeycloakContainerOptions { + envVars?: string[]; + imageVersion?: string; + imageName?: string; + adminUsername?: string; + adminPassword?: string; + logLevel?: LogLevelDesc; +} + +export const K_DEFAULT_KEYCLOAK_IMAGE_NAME = "jboss/keycloak"; +export const K_DEFAULT_KEYCLOAK_IMAGE_VERSION = "11.0.3"; +export const K_DEFAULT_KEYCLOAK_HTTP_PORT = 8080; + +/** + * Class responsible for programmatically managing a container that is running + * the image made for hosting a keycloak instance which can be used to test + * authorization/authentication related use-cases. + */ +export class KeycloakContainer { + public static readonly CLASS_NAME = "KeycloakContainer"; + private readonly log: Logger; + private readonly imageName: string; + private readonly imageVersion: string; + private readonly _adminUsername: string; + private readonly _adminPassword: string; + private readonly envVars: string[]; + private _container: Container | undefined; + private _containerId: string | undefined; + + public get imageFqn(): string { + return `${this.imageName}:${this.imageVersion}`; + } + + public get className(): string { + return KeycloakContainer.CLASS_NAME; + } + + public get container(): Container { + if (this._container) { + return this._container; + } else { + throw new Error(`Invalid state: _container is not set. Called start()?`); + } + } + + constructor(public readonly options: IKeycloakContainerOptions) { + const fnTag = `${this.className}#constructor()`; + Checks.truthy(options, `${fnTag} arg options`); + + const level = this.options.logLevel || "INFO"; + const label = this.className; + this.log = LoggerProvider.getOrCreate({ level, label }); + + this.imageName = this.options.imageName || K_DEFAULT_KEYCLOAK_IMAGE_NAME; + this.imageVersion = + this.options.imageVersion || K_DEFAULT_KEYCLOAK_IMAGE_VERSION; + this.envVars = this.options.envVars || []; + + this._adminPassword = options.adminPassword || uuidv4(); + this._adminUsername = options.adminUsername || "admin"; + + this.log.info(`Created ${this.className} OK. Image FQN: ${this.imageFqn}`); + } + + public async start(): Promise { + if (this._container) { + await this.container.stop(); + await this.container.remove(); + } + const docker = new Docker(); + + await Containers.pullImage(this.imageFqn); + + const Env = [ + ...[ + `KEYCLOAK_USER=${this._adminUsername}`, + `KEYCLOAK_PASSWORD=${this._adminPassword}`, + `PROXY_ADDRESS_FORWARDING=true`, + `DEBUG=true`, + `DEBUG_PORT='*:8787'`, + ], + ...this.envVars, + ]; + this.log.debug(`Effective Env of container: %o`, Env); + + const Healthcheck = { + Test: ["CMD-SHELL", `curl -v 'http://localhost:9990/'`], + Interval: 1000000000, // 1 second + Timeout: 3000000000, // 3 seconds + Retries: 99, + StartPeriod: 1000000000, // 1 second + }; + + return new Promise((resolve, reject) => { + const eventEmitter: EventEmitter = docker.run( + this.imageFqn, + [], + [], + { + Env, + PublishAllPorts: true, + Healthcheck, + }, + {}, + (err?: Error) => { + if (err) { + reject(err); + } + }, + ); + + eventEmitter.once("start", async (container: Container) => { + this._container = container; + this._containerId = container.id; + try { + await Containers.waitForHealthCheck(this._containerId); + resolve(container); + } catch (ex) { + reject(ex); + } + }); + }); + } + + // { + // authorizationURL: "https://www.example.com/oauth2/authorize", + // tokenURL: "https://www.example.com/oauth2/token", + // clientID: "EXAMPLE_CLIENT_ID", + // clientSecret: "EXAMPLE_CLIENT_SECRET", + // callbackURL: "http://localhost:3000/auth/example/callback", + // } + public async getOauth2Options(clientId = "account"): Promise { + const fnTag = `${this.className}#getOauth2Options()`; + const { log } = this; + const defaultRealm = await this.getDefaultRealm(); + const apiBaseUrl = await this.getApiBaseUrl(); + const kcAdminClient = await this.createAdminClient(); + const realm = defaultRealm.realm; + const realmBaseUrl = `${apiBaseUrl}/realms/${realm}`; + + const clients = await kcAdminClient.clients.find({}); + + const aClient = clients.find((c) => c.clientId === clientId); + if (!aClient) { + throw new Error(`${fnTag} could not find client with ID ${clientId}`); + } + + const secret = await kcAdminClient.clients.getClientSecret({ + id: aClient.id as string, + }); + + const oauth2Options = { + // http://:/auth/realms//protocol/openid-connect/token + authorizationURL: `${realmBaseUrl}/protocol/openid-connect/auth`, + tokenURL: `${realmBaseUrl}/protocol/openid-connect/token`, + clientID: clientId, + clientSecret: secret.value, + }; + log.debug(`OAuth2_Options for passport strategy: %o`, oauth2Options); + return oauth2Options; + } + + public async getOidcOptions(clientId = "account"): Promise { + const fnTag = `${this.className}#getOidcOptions()`; + const { log } = this; + const defaultRealm = await this.getDefaultRealm(); + const apiBaseUrl = await this.getApiBaseUrl(); + const kcAdminClient = await this.createAdminClient(); + const realm = defaultRealm.realm; + const realmBaseUrl = `${apiBaseUrl}/realms/${realm}`; + + const clients = await kcAdminClient.clients.find({}); + + const aClient = clients.find((c) => c.clientId === clientId); + if (!aClient) { + throw new Error(`${fnTag} could not find client with ID ${clientId}`); + } + + const secret = await kcAdminClient.clients.getClientSecret({ + id: aClient.id as string, + }); + + const oidcOptions = { + // http://:/auth/realms//protocol/openid-connect/token + authorizationURL: `${realmBaseUrl}/protocol/openid-connect/auth`, + tokenURL: `${realmBaseUrl}/protocol/openid-connect/token`, + userProfileURL: `${realmBaseUrl}/protocol/openid-connect/userinfo`, + clientID: clientId, + clientSecret: secret.value, + callbackURL: `${realmBaseUrl}/account?referrer=${clientId}`, + }; + log.debug(`OIDC_Options for passport strategy: %o`, oidcOptions); + return oidcOptions; + } + + // FIXME - this does not work yet + public async getSaml2Options( + clientId: string, + callbackUrl: string, + ): Promise { + const fnTag = `${this.className}#getSaml2Options()`; + Checks.truthy(clientId, `${fnTag}:clientId`); + const { log } = this; + const defaultRealm = await this.getDefaultRealm(); + const apiBaseUrl = await this.getApiBaseUrl("localhost"); + const realm = defaultRealm.realm; + const realmBaseUrl = `${apiBaseUrl}/realms/${realm}`; + + const kcAdminClient = await this.createAdminClient(); + const clients = await kcAdminClient.clients.find({}); + const client = clients.find((c) => c.clientId === clientId); + log.debug("SAML2 client: %o", JSON.stringify(client, null, 4)); + + // http://localhost:32819/auth/realms/master/protocol/saml + const saml2Opts = { + entryPoint: `${realmBaseUrl}/protocol/saml`, + // issuer: 'https://your-app.example.net/login/callback', + callbackUrl, + issuer: client?.clientId, + }; + log.debug(`SAML2_Options for passport strategy: %o`, saml2Opts); + return saml2Opts; + } + + public async getApiBaseUrl(host?: string): Promise { + const port = await this.getHostPortHttp(); + const lanIpV4OrUndefined = await internalIpV4(); + const lanAddress = host || lanIpV4OrUndefined || "127.0.0.1"; // best effort... + return `http://${lanAddress}:${port}/auth`; + } + + public async getDefaultRealm(): Promise { + const kcAdminClient = await this.createAdminClient(); + const [firstRealm] = await kcAdminClient.realms.find({}); + return firstRealm; + } + + public async createAdminClient(): Promise { + const baseUrl = await this.getApiBaseUrl(); + const kcAdminClient = new KcAdminClient({ + baseUrl, + realmName: "master", + requestConfig: { + /* Axios request config options https://github.com/axios/axios#request-config */ + }, + }); + + this.log.debug(`Authenticating against the Keycloak admin API...`); + // Authorize with username / password + await kcAdminClient.auth({ + username: this._adminUsername, + password: this._adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + this.log.debug(`Keycloak admin API auth OK`); + return kcAdminClient; + } + + public async ensureRealmExists( + realmRepresentation: RealmRepresentation, + ): Promise { + const fnTag = `${this.className}#ensureRealmExists()`; + Checks.truthy(realmRepresentation, `${fnTag}:realmRepresentation`); + Checks.nonBlankString( + realmRepresentation.realm, + `${fnTag}:realmRepresentation.realm`, + ); + const realmName = realmRepresentation.realm; + + const kcAdminClient = await this.createAdminClient(); + + this.log.debug(`Looking for realm with name ${realmName} ...`); + const realms = await kcAdminClient.realms.find({}); + this.log.debug(`Fetched a list of realms ${realms.length} long...`); + const aRealm = realms.find((r) => r.realm === realmName); + if (aRealm) { + this.log.debug(`Returning pre-existing realm, skip create: %o`, aRealm); + return aRealm; + } + + this.log.debug(`Creating ${realmName} realm... %o`, realmRepresentation); + const newRealm = await kcAdminClient.realms.create(realmRepresentation); + this.log.debug(`Created new realm: %o`, newRealm); + return newRealm; + } + + public async createTestUser( + payload?: UserRepresentation & { realm?: string }, + ): Promise<{ id: string }> { + const kcAdminClient = await this.createAdminClient(); + + // List all users + const users = await kcAdminClient.users.find(); + this.log.debug(`Users: %o`, users); + + // Override client configuration for all further requests: + // kcAdminClient.setConfig({ + // realmName: "another-realm", + // }); + + // This operation will now be performed in 'another-realm' if the user has access. + const groups = await kcAdminClient.groups.find(); + this.log.debug(`Groups: %o`, groups); + + // Set a `realm` property to override the realm for only a single operation. + // For example, creating a user in another realm: + const theUser = await kcAdminClient.users.create(payload); + this.log.debug(`Created new user: %o`, theUser); + return theUser; + } + + public async stop(): Promise { + if (this._container) { + await Containers.stop(this.container); + } + } + + public destroy(): Promise { + const fnTag = `${this.className}#destroy()`; + if (this._container) { + return this._container.remove(); + } else { + const ex = new Error(`${fnTag} Container not found, nothing to destroy.`); + return Promise.reject(ex); + } + } + + public async getHostPortHttp(): Promise { + const fnTag = `${this.className}#getHostPortHttp()`; + if (this._containerId) { + const cInfo = await Containers.getById(this._containerId); + return Containers.getPublicPort(K_DEFAULT_KEYCLOAK_HTTP_PORT, cInfo); + } else { + throw new Error(`${fnTag} Container ID not set. Did you call start()?`); + } + } +} diff --git a/packages/cactus-test-tooling/src/main/typescript/public-api.ts b/packages/cactus-test-tooling/src/main/typescript/public-api.ts index 83209874df..df05927814 100755 --- a/packages/cactus-test-tooling/src/main/typescript/public-api.ts +++ b/packages/cactus-test-tooling/src/main/typescript/public-api.ts @@ -69,6 +69,14 @@ export { ICordaConnectorContainerOptions, } from "./corda-connector/corda-connector-container"; +export { + IKeycloakContainerOptions, + K_DEFAULT_KEYCLOAK_HTTP_PORT, + K_DEFAULT_KEYCLOAK_IMAGE_NAME, + K_DEFAULT_KEYCLOAK_IMAGE_VERSION, + KeycloakContainer, +} from "./keycloak/keycloak-container"; + export { SAMPLE_CORDAPP_ROOT_DIRS, SampleCordappEnum,