diff --git a/Tokenization/backend/proto/wrapper.proto b/Tokenization/backend/proto/wrapper.proto index 9acaba2fd..649375b9d 100644 --- a/Tokenization/backend/proto/wrapper.proto +++ b/Tokenization/backend/proto/wrapper.proto @@ -45,6 +45,11 @@ message Token { ConnectionDirection connectionDirection = 3; } +// List of multiple tokens +message TokenList { + repeated Token tokens = 1; +} + // Stream message that can contain one of specific messages message Payload { // Message event type @@ -53,7 +58,9 @@ message Payload { // Data related to specific event type oneof data { EmptyMessage emptyMessage = 2; - Token payload = 3; + Token singleToken = 3; + TokenList tokensList = 4; + Alert alertMessage = 5; } } @@ -88,14 +95,22 @@ message HttpLikeResponse { // ====================================== enum MessageEvent { - // Default value, represents an empty event - MESSAGE_EVENT_EMPTY = 1; - - // New token message type, contains a new token and target address - MESSAGE_EVENT_NEW_TOKEN = 2; - - // Revoke token message type, contains a token to be revoked - MESSAGE_EVENT_REVOKE_TOKEN = 3; + // Central system commands + // Default value, represents an empty event + MESSAGE_EVENT_EMPTY = 0; + + // New token message type, contains a new token and target address + MESSAGE_EVENT_NEW_TOKEN = 1; + + // Revoke token message type, contains a token to be revoked + MESSAGE_EVENT_REVOKE_TOKEN = 2; + + // Client commands + // Get all tokens assigned to this client + MESSAGE_EVENT_GET_ALL_TOKENS = 3; + + // Renew a token after expiration + MESSAGE_EVENT_RENEW_TOKEN = 4; } enum ConnectionDirection { @@ -110,4 +125,33 @@ enum ConnectionDirection { // Duplex connection, both sending and receiving DUPLEX = 3; +} + +enum AlertLevel { + // Default value + ALERT_LEVEL_UNSPECIFIED = 0; + INFO = 1; + WARNING = 2; + ERROR = 3; + CRITICAL = 4; +} + +enum AlertCode { + ALERT_CODE_UNSPECIFIED = 0; + AUTH_NO_TOKEN = 1; + AUTH_JWE_DECRYPT_FAIL = 2; + AUTH_JWS_INVALID = 3; + AUTH_SN_MISMATCH = 4; + AUTH_METHOD_FORBIDDEN = 5; + AUTH_PERMISSION_EXPIRED = 6; + AUTH_CONN_BLOCKED = 7; + P2P_FORWARD_ERROR = 8; +} + +message Alert { + string alert = 1; + AlertLevel level = 2; + AlertCode code = 3; + int64 ts = 4; + map context = 5; } \ No newline at end of file diff --git a/Tokenization/backend/wrapper/package-lock.json b/Tokenization/backend/wrapper/package-lock.json index e04e08cea..83324580a 100644 --- a/Tokenization/backend/wrapper/package-lock.json +++ b/Tokenization/backend/wrapper/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@grpc/grpc-js": "^1.13.4", "@grpc/proto-loader": "^0.7.15", + "express": "^5.1.0", + "jose": "^6.1.0", "ts-node": "^10.9.2", "typescript": "^5.8.3" }, @@ -1827,6 +1829,19 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2111,6 +2126,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2198,6 +2237,44 @@ "dev": true, "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2391,6 +2468,28 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2398,6 +2497,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -2445,7 +2562,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2491,6 +2607,15 @@ "node": ">=0.10.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2533,6 +2658,26 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -2575,6 +2720,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -2585,6 +2739,36 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2594,6 +2778,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2895,6 +3085,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -2945,6 +3144,49 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3062,6 +3304,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -3097,6 +3360,24 @@ "dev": true, "license": "ISC" }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3108,7 +3389,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3133,6 +3413,30 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -3143,6 +3447,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -3235,6 +3552,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3259,11 +3588,22 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -3296,6 +3636,26 @@ "dev": true, "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -3306,6 +3666,22 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3389,9 +3765,17 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3480,6 +3864,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4199,6 +4589,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", + "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4429,6 +4828,36 @@ "tmpl": "1.0.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -4460,6 +4889,31 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -4487,7 +4941,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mylas": { @@ -4511,6 +4964,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4555,11 +5017,34 @@ "dev": true, "license": "MIT" }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -4703,6 +5188,15 @@ "dev": true, "license": "MIT" }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4740,6 +5234,16 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -4882,6 +5386,19 @@ "node": ">=12.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4909,6 +5426,21 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-lit": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", @@ -4940,6 +5472,30 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -5057,6 +5613,22 @@ "node": ">=0.10.0" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5081,6 +5653,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -5091,6 +5669,49 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5114,6 +5735,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -5214,6 +5907,15 @@ "node": ">=8" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -5365,6 +6067,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -5558,6 +6269,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -5601,6 +6326,15 @@ "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -5663,6 +6397,15 @@ "node": ">=10.12.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -5720,7 +6463,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/Tokenization/backend/wrapper/package.json b/Tokenization/backend/wrapper/package.json index 000944e17..e41284638 100644 --- a/Tokenization/backend/wrapper/package.json +++ b/Tokenization/backend/wrapper/package.json @@ -26,6 +26,8 @@ "dependencies": { "@grpc/grpc-js": "^1.13.4", "@grpc/proto-loader": "^0.7.15", + "express": "^5.1.0", + "jose": "^6.1.0", "ts-node": "^10.9.2", "typescript": "^5.8.3" } diff --git a/Tokenization/backend/wrapper/src/central/CentralSystemWrapper.ts b/Tokenization/backend/wrapper/src/central/CentralSystemWrapper.ts index 20f5e2457..b95901f22 100644 --- a/Tokenization/backend/wrapper/src/central/CentralSystemWrapper.ts +++ b/Tokenization/backend/wrapper/src/central/CentralSystemWrapper.ts @@ -15,7 +15,10 @@ import * as grpc from '@grpc/grpc-js'; import * as protoLoader from '@grpc/proto-loader'; import { LogManager } from '@aliceo2/web-ui'; -import type { DuplexMessageModel } from '../models/message.model'; +import { DuplexMessageModel } from '../models/message.model'; +import * as fs from 'fs'; +import type { CentralSystemConfig } from '../models/config.model'; +import { CentralCommandDispatcher } from '../client/connectionManager/eventManagement/CentralCommandDispatcher'; /** * @description Central System gRPC wrapper that manages client connections and handles gRPC streams with them. @@ -24,11 +27,16 @@ export class CentralSystemWrapper { // Config private _protoPath: string; - // Utilities - private _logger = LogManager.getLogger('CentralSystemWrapper'); - // Class properties private _server: grpc.Server; + private _port: number; + + // Certificates paths + private _serverCerts: CentralSystemConfig['serverCerts']; + + // Utilities + private _logger = LogManager.getLogger('CentralSystemWrapper'); + private _dispatcher = new CentralCommandDispatcher(); // Clients management private _clients = new Map>(); @@ -38,8 +46,22 @@ export class CentralSystemWrapper { * Initializes the Wrapper for CentralSystem. * @param port The port number to bind the gRPC server to. */ - constructor(protoPath: string, private port: number) { - this._protoPath = protoPath; + constructor(config: CentralSystemConfig) { + if (!config.protoPath || !config.serverCerts?.caCertPath || !config.serverCerts?.certPath || !config.serverCerts?.keyPath) { + throw new Error('Invalid CentralSystemConfig provided'); + } + + this._protoPath = config.protoPath; + this._serverCerts = config.serverCerts; + this._port = config.port ?? 50051; + + // Register command handlers if provided + if (config.commandHandlers) { + config.commandHandlers.forEach(({ command, handler }) => { + this._dispatcher.register(command, handler); + }); + } + this._server = new grpc.Server(); this.setupService(); } @@ -104,6 +126,7 @@ export class CentralSystemWrapper { // Listen for data events from the client call.on('data', (payload: any) => { this._logger.infoMessage(`Received from ${clientIp}:`, payload); + this._dispatcher.dispatch(payload); }); // Handle stream end event @@ -114,8 +137,8 @@ export class CentralSystemWrapper { }); // Handle stream error event - call.on('error', (err) => { - this._logger.infoMessage(`Stream error from client ${clientIp}:`, err); + call.on('error', (err: any) => { + this._logger.errorMessage(`Stream error from client ${clientIp}:`, err); this.cleanupClient(peer); }); } @@ -156,6 +179,17 @@ export class CentralSystemWrapper { } } + public broadcastEvent(data: DuplexMessageModel): void { + this._clients.forEach((client, ip) => { + try { + client.write(data); + this._logger.infoMessage(`Broadcasted event to ${ip}:`, data); + } catch (err) { + this._logger.errorMessage(`Error broadcasting to ${ip}:`, err); + } + }); + } + /** * Gets all connected client IPs * @returns Array of connected client IPs @@ -168,13 +202,30 @@ export class CentralSystemWrapper { * Starts the gRPC server and binds it to the specified in class port. */ public listen() { - const addr = `localhost:${this.port}`; - this._server.bindAsync(addr, grpc.ServerCredentials.createInsecure(), (err, _port) => { + const addr = `localhost:${this._port}`; + + // Create mTLS secure gRPC server + const caCert = fs.readFileSync(this._serverCerts.caCertPath); + const centralKey = fs.readFileSync(this._serverCerts.keyPath); + const centralCert = fs.readFileSync(this._serverCerts.certPath); + + const sslCreds = grpc.ServerCredentials.createSsl( + caCert, + [ + { + private_key: centralKey, + cert_chain: centralCert, + }, + ], + true + ); + + this._server.bindAsync(addr, sslCreds, (err: any, _port: any) => { if (err) { - this._logger.infoMessage('Server bind error:', err); + this._logger.errorMessage('Server bind error:', err); return; } - this._logger.infoMessage(`CentralSytem started listening on ${addr}`); + this._logger.infoMessage(`CentralSystem started listening on ${addr}`); }); } } diff --git a/Tokenization/backend/wrapper/src/central/Commands/getAllTokens/getAllTokens.command.ts b/Tokenization/backend/wrapper/src/central/Commands/getAllTokens/getAllTokens.command.ts new file mode 100644 index 000000000..d6eb157a5 --- /dev/null +++ b/Tokenization/backend/wrapper/src/central/Commands/getAllTokens/getAllTokens.command.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { Command } from "../../../models/commands.model"; +import { DuplexMessageEvent } from "../../../models/message.model"; + +/** + * @description Command used to retrieve all tokens for a client. Handles structure logic. + */ +export class GetAllTokensCommand implements Command { + readonly event = DuplexMessageEvent.MESSAGE_EVENT_GET_ALL_TOKENS; + constructor() {} +} diff --git a/Tokenization/backend/wrapper/src/central/Commands/renewToken/renewToken.command.ts b/Tokenization/backend/wrapper/src/central/Commands/renewToken/renewToken.command.ts new file mode 100644 index 000000000..66c2d704b --- /dev/null +++ b/Tokenization/backend/wrapper/src/central/Commands/renewToken/renewToken.command.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { SingleTokenPayload } from "../../../models/token.model"; +import { Command } from "../../../models/commands.model"; +import { DuplexMessageEvent } from "../../../models/message.model"; + +/** + * @description Command used to renew token for a client after its expiration. Handles structure logic. + */ +export class RenewTokenCommand implements Command { + readonly event = DuplexMessageEvent.MESSAGE_EVENT_RENEW_TOKEN; + constructor(payload: SingleTokenPayload) {} +} diff --git a/Tokenization/backend/wrapper/src/central/Commands/sendAlert/sendAlert.command.ts b/Tokenization/backend/wrapper/src/central/Commands/sendAlert/sendAlert.command.ts new file mode 100644 index 000000000..4f35f8c58 --- /dev/null +++ b/Tokenization/backend/wrapper/src/central/Commands/sendAlert/sendAlert.command.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { Command } from "../../../models/commands.model"; +import { DuplexMessageEvent } from "../../../models/message.model"; +import { AlertPayload } from "../../../models/alert.model"; + +/** + * @description Command used to send specific alert to central system logs. Handles structure logic. + */ +export class SendAlertCommand implements Command { + readonly event = DuplexMessageEvent.MESSAGE_EVENT_SEND_ALERT; + constructor(payload: AlertPayload) {} +} diff --git a/Tokenization/backend/wrapper/src/client/commands/newToken/newToken.command.ts b/Tokenization/backend/wrapper/src/client/commands/newToken/newToken.command.ts index c5f3a97eb..ee739f52b 100644 --- a/Tokenization/backend/wrapper/src/client/commands/newToken/newToken.command.ts +++ b/Tokenization/backend/wrapper/src/client/commands/newToken/newToken.command.ts @@ -12,8 +12,8 @@ * or submit itself to any jurisdiction. */ -import type { Command } from '../../../models/commands.model'; -import type { TokenMessage } from '../../../models/message.model'; +import { SingleTokenPayload } from '../../../models/token.model'; +import { Command } from '../../../models/commands.model'; import { DuplexMessageEvent } from '../../../models/message.model'; /** @@ -24,7 +24,7 @@ export class NewTokenCommand implements Command { /** * Constructor for NewTokenCommand. - * @param {TokenMessage} payload - TokenMessage containing the new token. + * @param {SingleTokenPayload} payload - TokenMessage containing the new token. */ - constructor(public payload: TokenMessage) {} + constructor(public payload: SingleTokenPayload) {} } diff --git a/Tokenization/backend/wrapper/src/client/commands/newToken/newToken.handler.ts b/Tokenization/backend/wrapper/src/client/commands/newToken/newToken.handler.ts index 4fb7eb54b..bb1dd89f2 100644 --- a/Tokenization/backend/wrapper/src/client/commands/newToken/newToken.handler.ts +++ b/Tokenization/backend/wrapper/src/client/commands/newToken/newToken.handler.ts @@ -36,7 +36,7 @@ export class NewTokenHandler implements CommandHandler { * @throws Will throw an error if any of the required payload fields are missing. */ async handle(command: NewTokenCommand): Promise { - const { targetAddress, connectionDirection, token } = command.payload || {}; + const { targetAddress, connectionDirection, token } = command.payload.singleToken || {}; if (!targetAddress || !token || !connectionDirection) { throw new Error('Insufficient arguments. Expected: targetAddress, connectionDirection, token.'); } @@ -47,7 +47,7 @@ export class NewTokenHandler implements CommandHandler { for (const dir of directions) { let conn = this.manager.getConnectionByAddress(targetAddress, dir); conn ??= await this.manager.createNewConnection(targetAddress, dir, token); - conn.token = token; + conn.handleNewToken(token); } } } diff --git a/Tokenization/backend/wrapper/src/client/commands/revokeToken/revokeToken.command.ts b/Tokenization/backend/wrapper/src/client/commands/revokeToken/revokeToken.command.ts index 071a5fcfb..d5917b2ca 100644 --- a/Tokenization/backend/wrapper/src/client/commands/revokeToken/revokeToken.command.ts +++ b/Tokenization/backend/wrapper/src/client/commands/revokeToken/revokeToken.command.ts @@ -12,8 +12,8 @@ * or submit itself to any jurisdiction. */ -import type { Command } from '../../../models/commands.model'; -import type { TokenMessage } from '../../../models/message.model'; +import { SingleTokenPayload } from '../../../models/token.model'; +import { Command } from '../../../models/commands.model'; import { DuplexMessageEvent } from '../../../models/message.model'; /** @@ -21,9 +21,10 @@ import { DuplexMessageEvent } from '../../../models/message.model'; */ export class RevokeTokenCommand implements Command { readonly event = DuplexMessageEvent.MESSAGE_EVENT_REVOKE_TOKEN; + /** * Constructor for RevokeTokenCommand. - * @param {TokenMessage} payload - TokenMessage containing the address and direction of the connection to be revoked. + * @param {SingleTokenPayload} payload - SingleTokenPayload containing the address and direction of the connection to be revoked. */ - constructor(public payload: TokenMessage) {} + constructor(public payload: SingleTokenPayload) {} } diff --git a/Tokenization/backend/wrapper/src/client/commands/revokeToken/revokeToken.handler.ts b/Tokenization/backend/wrapper/src/client/commands/revokeToken/revokeToken.handler.ts index 2fa1a9640..6bdb7ab87 100644 --- a/Tokenization/backend/wrapper/src/client/commands/revokeToken/revokeToken.handler.ts +++ b/Tokenization/backend/wrapper/src/client/commands/revokeToken/revokeToken.handler.ts @@ -33,15 +33,15 @@ export class RevokeTokenHandler implements CommandHandler { * Handles the RevokeTokenCommand by revoking the token on the target connection. * * @param command - The RevokeTokenCommand containing the target address and direction. - * @throws Will throw an error if the target address is missing in the command payload. + * @throws Will throw an error if the target address or direction is missing in the command payload. */ async handle(command: RevokeTokenCommand): Promise { - const { targetAddress } = command.payload || {}; - if (!targetAddress) { - throw new Error('Target address is required to revoke token.'); + const { targetAddress, connectionDirection } = command.payload.singleToken || {}; + if (!targetAddress || !connectionDirection) { + throw new Error('Target address and connection direction are required to revoke token.'); } - const conn = this.manager.getConnectionByAddress(targetAddress, command.payload.connectionDirection); + const conn = this.manager.getConnectionByAddress(targetAddress, connectionDirection); conn?.handleRevokeToken(); } diff --git a/Tokenization/backend/wrapper/src/client/commands/sendAllTokens/sendAllTokens.command.ts b/Tokenization/backend/wrapper/src/client/commands/sendAllTokens/sendAllTokens.command.ts new file mode 100644 index 000000000..58f129d9e --- /dev/null +++ b/Tokenization/backend/wrapper/src/client/commands/sendAllTokens/sendAllTokens.command.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { TokenListPayload } from "../../../models/token.model"; +import { Command } from "../../../models/commands.model"; +import { DuplexMessageEvent } from "../../../models/message.model"; + +/** + * @description Command used to handle all tokens from a central system. Handles structure logic. + */ +export class SendAllTokensCommand implements Command { + readonly event = DuplexMessageEvent.MESSAGE_EVENT_SEND_ALL_TOKENS; + constructor(public payload: TokenListPayload) {} +} diff --git a/Tokenization/backend/wrapper/src/client/commands/sendAllTokens/sendAllTokens.handler.ts b/Tokenization/backend/wrapper/src/client/commands/sendAllTokens/sendAllTokens.handler.ts new file mode 100644 index 000000000..a7e09adc0 --- /dev/null +++ b/Tokenization/backend/wrapper/src/client/commands/sendAllTokens/sendAllTokens.handler.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { CommandHandler } from '../../../models/commands.model'; +import { SendAllTokensCommand } from './sendAllTokens.command'; +import { ConnectionManager } from '../../connectionManager/ConnectionManager'; +import { ConnectionDirection } from '../../../models/message.model'; + +export class SendAllTokensHandler implements CommandHandler { + /** + * Creates a new instance of RevokeTokenHandler. + * + * @param manager - The ConnectionManager used to retrieve active connections. + */ + constructor(private manager: ConnectionManager) {} + + /** + * Handles the RevokeTokenCommand by revoking the token on the target connection. + * + * @param command - The RevokeTokenCommand containing the target address and direction. + * @throws Will throw an error if the target address or direction is missing in the command payload. + */ + async handle(command: SendAllTokensCommand): Promise { + const { tokensList } = command.payload; + + for (const token of tokensList) { + this.manager.createNewConnection(token.targetAddress, token.connectionDirection || ConnectionDirection.SENDING, token.token || ''); + } + } +} diff --git a/Tokenization/backend/wrapper/src/client/connection/Connection.ts b/Tokenization/backend/wrapper/src/client/connection/Connection.ts index 3f27c31b5..dcd065c12 100644 --- a/Tokenization/backend/wrapper/src/client/connection/Connection.ts +++ b/Tokenization/backend/wrapper/src/client/connection/Connection.ts @@ -12,52 +12,171 @@ * or submit itself to any jurisdiction. */ -import type { ConnectionDirection } from '../../models/message.model'; -import type { ConnectionHeaders, FetchOptions, FetchResponse } from '../../models/connection.model'; -import { ConnectionStatus } from '../../models/connection.model'; +import { ConnectionDirection } from '../../models/message.model'; +import { ConnectionStatus, type ConnectionHeaders, type FetchOptions, type FetchResponse } from '../../models/connection.model'; import * as grpc from '@grpc/grpc-js'; +import { LogManager } from '@aliceo2/web-ui'; +import { RetryQueue, type RetryTask } from '../../utils/queues/RetryQueue'; +import { genId } from '../../utils/custom.identifier'; +import { TOKEN_REASON_HEADER, TokenAuthReason, type TokenPayload } from '../../models/token.model'; + +type ConnectionCerts = { + caCert: Buffer; + clientCert: Buffer; + clientKey: Buffer; +}; /** * @description This class represents a connection to a target client and manages sending messages to it. */ export class Connection { - private _token: string; - private _targetAddress: string; + // Constants + private static readonly MAX_AUTH_FAILURES = 5; + private static readonly MAX_FAILED_LOG_SIZE = 100; + + private _jweToken: string; private _status: ConnectionStatus; - private _peerClient: any; + private _peerClient?: any; // A client grpc connection instance + + // Security management variables + private _clientSerialNumber?: string; // The certificate SN used to uniquely identify the peer. + private _lastActiveTimestamp: number; // Timestamp of the last successful request (for garbage collection). + private _authFailures: number; // Counter for consecutive authentication failures (for anti-DDoS/throttling). + private _cachedTokenPayload?: TokenPayload; // Cache of the successfully verified token payload. + + private _targetAddress: string; public direction: ConnectionDirection; + // Utils + private _logger; + private _retryQueue = new RetryQueue({ + maxRetries: 5, + baseDelayMs: 300, + maxDelayMs: 8000, + jitter: true, + }); + private _pendingTokenRefresh?: Promise; + private _isRefreshing = false; + private _tokenRefreshLock = false; // Mutex for token refresh to prevent race conditions + + // For debug purposes - circular buffer with max size to prevent memory leaks + private _failedRequestsLog: Array<{ + id: string; + method: string; + path: string; + reason: string; + at: number; + tryNo: number; + }> = []; + /** * Creates a new Connection instance with the given token, target address, and connection direction. * - * @param token - The authentication token for the connection. + * @param jweToken - The encrypted JWE token for the connection. * @param targetAddress - The unique address of the target client. * @param direction - The direction of the connection (e.g., sending or receiving). + * @param clientSN - Optional serial number of the peer's certificate (used for lookups). */ - constructor(token: string, targetAddress: string, direction: ConnectionDirection, peerCtor: any) { - this._token = token; + constructor( + jweToken: string, + targetAddress: string, + direction: ConnectionDirection, + private renewToken: (token: string, targetAddress: string) => void, + clientSN?: string + ) { + this._jweToken = jweToken; this._targetAddress = targetAddress; - this._peerClient = new peerCtor(targetAddress, grpc.credentials.createInsecure()); this.direction = direction; + // Initialize state fields + this._clientSerialNumber = clientSN; + this._lastActiveTimestamp = Date.now(); + this._authFailures = 0; this._status = ConnectionStatus.CONNECTED; + + this._logger = LogManager.getLogger(`Connection ${targetAddress}`); + } + + /** + * Creates the mTLS gRPC client and attaches it to the connection. + * This method is REQUIRED ONLY for outbound (SENDING) connections. + * * @param peerCtor - The constructor for the gRPC client to be used for communication. + * @param connectionCerts - Required sending client certificates for mTLS. + */ + public createSslTunnel(peerCtor: any, connectionCerts: ConnectionCerts): void { + if (this.direction !== ConnectionDirection.SENDING) { + this._logger.warnMessage('Attempted to create SSL tunnel on a RECEIVING connection. This is usually unnecessary.'); + } + + if (!connectionCerts.caCert || !connectionCerts.clientCert || !connectionCerts.clientKey) { + throw new Error('Connection certificates are required to create an mTLS tunnel.'); + } + + // Create grpc credentials + const sslCreds = grpc.credentials.createSsl(connectionCerts.caCert, connectionCerts.clientKey, connectionCerts.clientCert); + + this._peerClient = new peerCtor(this.targetAddress, sslCreds); + this.status = ConnectionStatus.CONNECTED; } /** * Replace newly generated token - * @param token New token to be replaced + * @param jweToken New token to be replaced */ - public set token(token: string) { - this._token = token; + public handleNewToken(jweToken: string): void { + this._jweToken = jweToken; + + // Reset + this._authFailures = 0; + this.status = ConnectionStatus.CONNECTED; + + // Drain retry queue on all after hanling new token + this._retryQueue.drainNow(); + + // End of refreshing + this._isRefreshing = false; + const p = this._pendingTokenRefresh; + this._pendingTokenRefresh = undefined; + // If someone awaited pendingTokenRefresh then we resolve it: + if (p) { + (p as any)._resolve?.(); // CreateTokenRefreshPromise + } } /** - * Revokes the token of the connection, effectively invalidating it. - * The connection status is set to UNAUTHORIZED. + * Revoke current token and set status of unauthorized connection */ public handleRevokeToken(): void { - this._token = ''; - this._status = ConnectionStatus.UNAUTHORIZED; + this._jweToken = ''; + this.status = ConnectionStatus.UNAUTHORIZED; + } + + /** + * Handles a successful authentication event. Updates the active timestamp, + * resets the failure counter, and caches the new token payload. + * This is crucial for high-performance applications to avoid re-validating the same token. + * @param payload The decoded and verified token payload. + */ + public handleSuccessfulAuth(payload: TokenPayload): void { + this._lastActiveTimestamp = Date.now(); + this._authFailures = 0; + this._cachedTokenPayload = payload; + this.status = ConnectionStatus.CONNECTED; + } + + /** + * Handles an authentication failure. Increments the failure counter. + * If the failure count exceeds a local threshold, the connection is locally marked as BLOCKED. + * @returns The new count of consecutive failures. + */ + public handleFailedAuth(): number { + this._authFailures += 1; + + // Local throttling mechanism + if (this._authFailures >= Connection.MAX_AUTH_FAILURES) { + this.status = ConnectionStatus.BLOCKED; + } + return this._authFailures; } /** @@ -65,20 +184,20 @@ export class Connection { * @returns Connection token */ public get token(): string { - return this._token; + return this._jweToken; } /** * Returns status for specific * @returns Connection status */ - public get status(): string { + public get status() { return this._status; } /** - * Sets the status of this connection. - * @param status The new status of the connection. + * Updates the status of the connection. + * @param status New status */ public set status(status: ConnectionStatus) { this._status = status; @@ -92,47 +211,275 @@ export class Connection { return this._targetAddress; } + /** + * Returns the client's Serial Number (SN). + * @returns The client's serial number or undefined. + */ + public get serialNumber(): string | undefined { + return this._clientSerialNumber; + } + + /** + * Sets the client's Serial Number. Primarily used for RECEIVING connections + * where the SN is extracted during the first mTLS handshake in the interceptor. + * @param serialNumber The serial number string. + */ + public set serialNumber(serialNumber: string) { + this._clientSerialNumber = serialNumber; + } + + /** + * Returns the timestamp of the last successful interaction. + * @returns UNIX timestamp in milliseconds. + */ + public get lastActiveTimestamp(): number { + return this._lastActiveTimestamp; + } + + /** + * Returns the cached token payload. + * @returns The cached payload or undefined. + */ + public get cachedTokenPayload(): TokenPayload | undefined { + return this._cachedTokenPayload; + } + + /** + * Attaches gRPC client to that connection + */ + public attachGrpcClient(client: any): void { + this._peerClient = client; + } + + // ----------------------------------------------------------------------------------------------------------------------------- + // FETCH HANDLING SECTION + // ----------------------------------------------------------------------------------------------------------------------------- + /** + * Waits for the token to be refreshed. + * + * @remarks + * This method is used internally to synchronize multiple concurrent requests + * that need to wait for a token refresh to complete before proceeding. + * + * @returns A promise that resolves when the token refresh is complete, or immediately if no refresh is pending. + */ + private async awaitTokenRefresh(): Promise { + if (!this._pendingTokenRefresh) return; + return this._pendingTokenRefresh; + } + + /** + * Creates a promise that resolves when a new token is refreshed. + * + * @remarks + * This method implements a promise-based synchronization mechanism for token refresh operations. + * It prevents race conditions by: + * 1. Checking if a refresh is already in progress and returning the existing promise + * 2. Using a lock mechanism (_tokenRefreshLock) to ensure atomic promise creation + * 3. Storing resolve/reject callbacks on the promise object for external resolution + * + * The promise is resolved externally by the `handleNewToken()` method when the central + * system provides a new token. + * + * @returns A promise that resolves when the token is refreshed. + */ + private createTokenRefreshPromise(): Promise { + // Return existing promise if already created + if (this._pendingTokenRefresh) return this._pendingTokenRefresh; + + // Acquire lock to prevent race condition during promise creation + if (this._tokenRefreshLock) { + // Another thread is creating the promise, wait a bit and retry + return new Promise((resolve) => { + setTimeout(() => resolve(this.createTokenRefreshPromise()), 10); + }); + } + + this._tokenRefreshLock = true; + + // Double-check after acquiring lock + if (this._pendingTokenRefresh) { + this._tokenRefreshLock = false; + return this._pendingTokenRefresh; + } + + let _resolve!: () => void; + let _reject!: (e: any) => void; + const newPromise = new Promise((resolve, reject) => { + _resolve = resolve; + _reject = reject; + }) as any; + + // Add reference to be resolved by handleNewToken + newPromise._resolve = _resolve; + newPromise._reject = _reject; + this._pendingTokenRefresh = newPromise; + + this._tokenRefreshLock = false; + return newPromise; + } + + /** + * Triggers token renewal if not already in progress. + * + * @remarks + * This method initiates the token refresh process when authentication fails due to + * specific reasons (expired, missing, or forbidden permissions). It: + * 1. Checks if a refresh is already in progress to avoid duplicate requests + * 2. Updates the connection status to TOKEN_REFRESH + * 3. Creates a promise that will be resolved when the new token arrives + * 4. Calls the renewToken callback to request a new token from the central system + * + * @param reason The reason for the token renewal (from TokenAuthReason enum). + */ + private triggerTokenRenewIfNeeded(reason: TokenAuthReason) { + if ( + this._isRefreshing || + (reason !== TokenAuthReason.PERMISSION_EXPIRED && reason !== TokenAuthReason.NO_TOKEN && reason !== TokenAuthReason.PERMISSION_FORBIDDEN) + ) + return; + this._isRefreshing = true; + this._status = ConnectionStatus.TOKEN_REFRESH; + this.createTokenRefreshPromise(); // Sets pendingTokenRefresh + this._logger.warnMessage(`Trigger token renew due to: ${TokenAuthReason[reason]}`); + this.renewToken(this._jweToken, this._targetAddress); + } + + /** + * Performs a fetch-like request over a gRPC connection. + * Returns a promise that resolves with a FetchResponse object, containing the response status, headers, and body. + * The body can be accessed as a Buffer, or as a string or JSON object using the text() and json() methods respectively. + * @param req The request object to be sent over the gRPC connection. + * @param metadata The metadata object to be sent with the request. + * @returns A promise that resolves with a FetchResponse object. + */ + private grpcFetch(req: any, metadata: grpc.Metadata): Promise { + return new Promise((resolve, reject) => { + if (!this._peerClient) { + return reject(new Error(`Peer client not attached for ${this._targetAddress}`)); + } + + this._peerClient.Fetch(req, metadata, (err: any, resp: any) => { + if (err) return reject(err); + + const resBody = resp?.body ? Buffer.from(resp.body) : Buffer.alloc(0); + resolve({ + status: Number(resp?.status ?? 200), + headers: resp?.headers ?? {}, + body: resBody, + text: async () => resBody.toString('utf8'), + json: async () => JSON.parse(resBody.toString('utf8')), + }); + }); + }); + } + + /** + * Checks if the given TokenAuthReason is eligible for token renewal. + * @param reason The reason for the authentication failure, or undefined if the authentication succeeded. + * @returns True if the reason is eligible for token renewal, false otherwise. + */ + private isAuthRenewable(reason: TokenAuthReason | undefined): boolean { + return ( + reason === TokenAuthReason.NO_TOKEN || + reason === TokenAuthReason.PERMISSION_EXPIRED || + reason === TokenAuthReason.JWE_DECRYPT_FAIL || + reason === TokenAuthReason.JWS_INVALID || + reason === TokenAuthReason.SERIAL_MISMATCH + ); + } + /** * "HTTP-like" fetch via gRPC protocol * @returns Promise with peer's response */ - public fetch(options: FetchOptions = {}): Promise { + public async fetch(options: FetchOptions = {}): Promise { if (!this._peerClient) { - return Promise.reject(new Error(`Peer client not attached for ${this.targetAddress}`)); + throw new Error(`Peer client not attached for ${this._targetAddress}`); + } + if (this.status === ConnectionStatus.BLOCKED) { + throw new Error('Connection is blocked. Contact your admin for further details.'); } - // Build a request object const method = (options.method ?? 'POST').toUpperCase(); const path = options.path ?? '/'; const headers: ConnectionHeaders = { ...(options.headers ?? {}) }; + const metadata = new grpc.Metadata(); + metadata.set('jwetoken', this._jweToken); + let bodyBuf: Buffer = Buffer.alloc(0); const b = options.body; if (b != null) { if (Buffer.isBuffer(b)) bodyBuf = b; else if (b instanceof Uint8Array) bodyBuf = Buffer.from(b); else if (typeof b === 'string') bodyBuf = Buffer.from(b, 'utf8'); - else return Promise.reject(new Error('Body must be a string/Buffer/Uint8Array')); + else throw new Error('Body must be a string/Buffer/Uint8Array'); } const req = { method, path, headers, body: bodyBuf }; - // Return promise with response - return new Promise((resolve, reject) => { - this._peerClient.Fetch(req, (err: any, resp: any) => { - if (err) return reject(err); + // If someone is already refreshing token then wait and retry + if (this.status === ConnectionStatus.TOKEN_REFRESH) { + await this.awaitTokenRefresh(); + // After refresh we have new token to setup in metadata + const meta = new grpc.Metadata(); + meta.set('jwetoken', this._jweToken); + return this.grpcFetch(req, meta); + } - const resBody = resp?.body ? Buffer.from(resp.body) : Buffer.alloc(0); - const fetchResponse: FetchResponse = { - status: Number(resp?.status ?? 200), - headers: resp?.headers ?? {}, - body: resBody, - text: async () => resBody.toString('utf8'), - json: async () => JSON.parse(resBody.toString('utf8')), + // First try of request fetching + try { + return await this.grpcFetch(req, metadata); + } catch (err: any) { + const reason: TokenAuthReason | undefined = err?.metadataMap?.get?.(TOKEN_REASON_HEADER); + + if (!this.isAuthRenewable(reason)) { + // Errors that are not eligible for token renewal (e.g. FORBIDDEN, BLOCKED, INTERNAL, etc.) + this._logger.errorMessage(`Error fetching for ${this.targetAddress}: (${method}):`, err); + throw err; + } + + // Queue and refresh section + const id = genId(); // Id of the request + + // Add to log with circular buffer to prevent memory leaks + if (this._failedRequestsLog.length >= Connection.MAX_FAILED_LOG_SIZE) { + this._failedRequestsLog.shift(); // Remove oldest entry + } + this._failedRequestsLog.push({ + id, + method, + path, + reason: TokenAuthReason[reason!], + at: Date.now(), + tryNo: 1, + }); + + // Trigger renewal + this.triggerTokenRenewIfNeeded(reason!); + + // Create retry fetch object that will resolve when the token is refreshed + return new Promise((resolve, reject) => { + const task: RetryTask = { + id, + tryNo: 1, + createdAt: Date.now(), + reason: TokenAuthReason[reason!], + // Retry function – will be executed after a drainNow() or after a backoff + exec: async () => { + // If still refreshing token then wait + await this.awaitTokenRefresh(); + const meta = new grpc.Metadata(); + meta.set('jwetoken', this._jweToken); + return this.grpcFetch(req, meta); + }, + resolve, + reject, }; - resolve(fetchResponse); + this._retryQueue.enqueue(task); }); - }); + } } } diff --git a/Tokenization/backend/wrapper/src/client/connectionManager/CentralConnection.ts b/Tokenization/backend/wrapper/src/client/connectionManager/CentralConnection.ts index 88f6bfb15..806fdeb86 100644 --- a/Tokenization/backend/wrapper/src/client/connectionManager/CentralConnection.ts +++ b/Tokenization/backend/wrapper/src/client/connectionManager/CentralConnection.ts @@ -67,6 +67,27 @@ export class CentralConnection { }); } + /** + * Sends an event to the central system via the gRPC stream. + * @param data Message with event type and corresponding payload + * @returns + */ + public sendEvent(data: DuplexMessageModel) { + if (!this._stream) { + this._logger.warnMessage(`Stream is not defined. Connect to Central System first.`); + return false; + } + + try { + this._stream?.write(data); + this._logger.infoMessage(`Sent event to Central System:`, data); + return true; + } catch (err) { + this._logger.errorMessage(`Error sending to Central System:`, err); + return false; + } + } + /** * Starts the connection to the central system. */ diff --git a/Tokenization/backend/wrapper/src/client/connectionManager/ConnectionManager.ts b/Tokenization/backend/wrapper/src/client/connectionManager/ConnectionManager.ts index fdbd20263..1e0de1230 100644 --- a/Tokenization/backend/wrapper/src/client/connectionManager/ConnectionManager.ts +++ b/Tokenization/backend/wrapper/src/client/connectionManager/ConnectionManager.ts @@ -19,11 +19,16 @@ import { CentralCommandDispatcher } from './eventManagement/CentralCommandDispat import { Connection } from '../connection/Connection'; import { LogManager } from '@aliceo2/web-ui'; import type { Command, CommandHandler } from 'models/commands.model'; -import type { DuplexMessageEvent } from '../../models/message.model'; +import { DuplexMessageEvent } from '../../models/message.model'; import { ConnectionDirection } from '../../models/message.model'; import { ConnectionStatus } from '../../models/connection.model'; +import type { SecurityContext } from '../../utils/security/SecurityContext'; import { peerListener } from '../../utils/connection/peerListener'; +import { AlertPayload } from '../../models/alert.model'; +/** + * @description Manages all the connection between clients and central system. + */ /** * Manages the lifecycle and connection logic for a gRPC client communicating with the central system. * @@ -40,14 +45,16 @@ import { peerListener } from '../../utils/connection/peerListener'; */ export class ConnectionManager { private _logger = LogManager.getLogger('ConnectionManager'); + private _wrapper: any; // GRPC wrapper file + private _centralDispatcher: CentralCommandDispatcher; private _centralConnection: CentralConnection; private _sendingConnections = new Map(); + private _receivingConnections = new Map(); - private _wrapper: any; - private _peerCtor: any; - private _peerServer: grpc.Server | undefined; - private _baseAPIPath: string = ''; + private _peerCtor: any; // P2P gRPC constructor + private _peerServer?: grpc.Server; + private _baseAPIPath: string = 'localhost:40041/api/'; /** * Initializes a new instance of the ConnectionManager class. @@ -56,8 +63,9 @@ export class ConnectionManager { * * @param protoPath - The file path to the gRPC proto definition. * @param centralAddress - The address of the central gRPC server (default: "localhost:50051"). + * @param securityContext - The security context containing certificates and keys for secure communication. */ - constructor(protoPath: string, centralAddress: string = 'localhost:50051') { + constructor(protoPath: string, centralAddress: string = 'localhost:50051', private readonly securityContext: SecurityContext) { const packageDef = protoLoader.loadSync(protoPath, { keepCase: true, longs: String, @@ -70,11 +78,17 @@ export class ConnectionManager { this._wrapper = proto.webui.tokenization; this._peerCtor = this._wrapper.Peer2Peer; - const client = new this._wrapper.CentralSystem(centralAddress, grpc.credentials.createInsecure()); + // Create grpc credentials + const sslCreds = grpc.credentials.createSsl( + this.securityContext.caCert, + this.securityContext.clientPrivateKey, + this.securityContext.clientSenderCert + ); + const centralClient = new this._wrapper.CentralSystem(centralAddress, sslCreds); // Event dispatcher for central system events this._centralDispatcher = new CentralCommandDispatcher(); - this._centralConnection = new CentralConnection(client, this._centralDispatcher, centralAddress); + this._centralConnection = new CentralConnection(centralClient, this._centralDispatcher, centralAddress); } /** @@ -110,14 +124,35 @@ export class ConnectionManager { * Creates new connection * @param address Target (external) address of the connection * @param direction Direction of connection - * @param token Optional token for connection + * @param jweToken Optional encrypted JWE token for connection */ - createNewConnection(address: string, direction: ConnectionDirection, token?: string) { - const conn = new Connection(token ?? '', address, direction, this._peerCtor); + public async createNewConnection(address: string, direction: ConnectionDirection, jweToken?: string) { + let conn: Connection | undefined; + + // Checks if connection already exists + conn = direction === ConnectionDirection.RECEIVING ? this._receivingConnections.get(address) : this._sendingConnections.get(address); + + // Return existing connection if found + if (conn) { + if (jweToken) { + conn.handleNewToken(jweToken); + } + return conn; + } + + // Create new connection + conn = new Connection(jweToken ?? '', address, direction, this.renewToken.bind(this)); + conn.status = ConnectionStatus.CONNECTING; if (direction === ConnectionDirection.RECEIVING) { this._receivingConnections.set(address, conn); } else { + // Open tunnel only on sending connections + conn.createSslTunnel(this._peerCtor, { + caCert: this.securityContext.caCert, + clientCert: this.securityContext.clientSenderCert, + clientKey: this.securityContext.clientPrivateKey, + }); this._sendingConnections.set(address, conn); } conn.status = ConnectionStatus.CONNECTED; @@ -143,9 +178,48 @@ export class ConnectionManager { } /** - * Returns all saved connections. - * - * @returns An object containing the sending and receiving connections. + * Retrieves a connection instance by its token. + * @param {string} token - The token to search for. + * @returns {Connection | undefined} The connection instance with the matching token, or undefined if not found. + */ + getConnectionByToken(token: string): Connection | undefined { + for (const conn of this._sendingConnections.values()) { + if (conn.token === token) { + return conn; + } + } + for (const conn of this._receivingConnections.values()) { + if (conn.token === token) { + return conn; + } + } + return undefined; + } + + /** + * Searches through all receiving and sending connections to find a connection by its client Serial Number (SN). + * @param serialNumber The unique serial number of the peer's certificate. + * @returns The matching Connection object or undefined. + */ + getConnectionBySerialNumber(serialNumber: string): Connection | undefined { + // Check receiving connections first + for (const conn of this._receivingConnections.values()) { + if (conn.serialNumber === serialNumber) { + return conn; + } + } + // Check sending connections + for (const conn of this._sendingConnections.values()) { + if (conn.serialNumber === serialNumber) { + return conn; + } + } + return undefined; + } + + /** + * Returns object with all connections + * @returns Object of all connections */ public get connections(): { sending: Connection[]; @@ -159,8 +233,18 @@ export class ConnectionManager { /** Starts a listener server for p2p connections */ public async listenForPeers(port: number, baseAPIPath?: string): Promise { + // Validate port number + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid port number: ${port}. Port must be an integer between 1 and 65535.`); + } + if (baseAPIPath) this._baseAPIPath = baseAPIPath; + if (!this.securityContext.clientListenerCert) { + this._logger.errorMessage('Listener certificate not provided in gRPCWrapper. Cannot start peer listener.'); + return; + } + if (this._peerServer) { this._peerServer.forceShutdown(); this._peerServer = undefined; @@ -169,13 +253,79 @@ export class ConnectionManager { this._peerServer = new grpc.Server(); this._peerServer.addService(this._wrapper.Peer2Peer.service, { Fetch: async (call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData) => - peerListener(call, callback, this._logger, this._receivingConnections, this._peerCtor, this._baseAPIPath), + peerListener( + call, + callback, + this._logger, + this._receivingConnections, + this.createNewConnection.bind(this), + this.securityContext, + this._baseAPIPath + ), }); + const sslCreds = grpc.ServerCredentials.createSsl( + this.securityContext.caCert, + [ + { + private_key: this.securityContext.clientPrivateKey, + cert_chain: this.securityContext.clientListenerCert, + }, + ], + true + ); + await new Promise((resolve, reject) => { - this._peerServer?.bindAsync(`localhost:${port}`, grpc.ServerCredentials.createInsecure(), (err) => (err ? reject(err) : resolve())); + this._peerServer?.bindAsync(`localhost:${port}`, sslCreds, (err) => (err ? reject(err) : resolve())); }); this._logger.infoMessage(`Peer server listening on localhost:${port}`); } + + /** + * Sends command to central system to get all tokens + * @returns Promise - central system will send asynchronously MESSAGE_EVENT_SEND_ALL_TOKENS with TokenListPayload interface + */ + public getAllTokens(): void { + this._centralConnection.sendEvent({ + event: DuplexMessageEvent.MESSAGE_EVENT_GET_ALL_TOKENS, + }); + } + + /** + * Sends command to central system to renew token for a specific target connection + * @param expiredToken token that needs to be renew + * @param targetAddress target address we want to send to + * @returns Promise - central system will send asynchronously MESSAGE_EVENT_NEW_TOKEN with SingleTokenPayload interface + */ + private renewToken(expiredToken: string, targetAddress: string): void { + const conn = this._sendingConnections.get(targetAddress); + if (!conn) { + return; + } + + this._centralConnection.sendEvent({ + event: DuplexMessageEvent.MESSAGE_EVENT_RENEW_TOKEN, + payload: { + singleToken: { + token: expiredToken, + targetAddress: targetAddress, + }, + }, + }); + } + + /** + * Sends alert to the central system. + * @param alert AlertPayload containing alert code, level, code, and timestamp. + * @returns void + */ + public sendCentralAlert(alert: AlertPayload): void { + this._centralConnection.sendEvent({ + event: DuplexMessageEvent.MESSAGE_EVENT_SEND_ALERT, + payload: { + ...alert, + }, + }); + } } diff --git a/Tokenization/backend/wrapper/src/client/connectionManager/eventManagement/CentralCommandDispatcher.ts b/Tokenization/backend/wrapper/src/client/connectionManager/eventManagement/CentralCommandDispatcher.ts index 64b557bce..a5a9b4a74 100644 --- a/Tokenization/backend/wrapper/src/client/connectionManager/eventManagement/CentralCommandDispatcher.ts +++ b/Tokenization/backend/wrapper/src/client/connectionManager/eventManagement/CentralCommandDispatcher.ts @@ -13,8 +13,8 @@ */ import { LogManager } from '@aliceo2/web-ui'; -import type { Command, CommandHandler } from 'models/commands.model'; -import type { DuplexMessageEvent } from '../../../models/message.model'; +import { Command, CommandHandler } from '../../../models/commands.model'; +import { DuplexMessageEvent } from '../../../models/message.model'; /** * CentralCommandDispatcher is responsible for registering and dispatching command handlers diff --git a/Tokenization/backend/wrapper/src/client/connectionManager/interceptors/grpc.auth.interceptor.ts b/Tokenization/backend/wrapper/src/client/connectionManager/interceptors/grpc.auth.interceptor.ts new file mode 100644 index 000000000..80a4ac58b --- /dev/null +++ b/Tokenization/backend/wrapper/src/client/connectionManager/interceptors/grpc.auth.interceptor.ts @@ -0,0 +1,412 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import * as grpc from '@grpc/grpc-js'; +import { Connection } from '../../../client/connection/Connection'; +import { importPKCS8, importJWK, compactDecrypt, compactVerify } from 'jose'; +import { ConnectionStatus } from '../../../models/connection.model'; +import { ConnectionDirection } from '../../../models/message.model'; +import { SecurityContext } from '../../../utils/security/SecurityContext'; +import { ConnectionManager } from '../ConnectionManager'; +import { TOKEN_REASON_HEADER, TOKEN_TARGET_HEADER, TokenAuthReason, TokenPayload } from '../../../models/token.model'; +import { AlertCode, AlertContext, makeAlert, codeFromTokenReason } from '../../../models/alert.model'; + +/** + * Interceptor function responsible for JWE decryption, JWS verification, + * certificate serial number matching (mTLS binding), and basic authorization. + */ +export class GRPCAuthInterceptor { + /** + * Creates a new instance of GRPCAuthInterceptor. + * + * @param connectionManager - Instance of ConnectionManager used to retrieve active connections. + * @param securityContext - Instance of SecurityContext used to access certificates and keys for JWE decryption and JWS verification. + */ + constructor(private connectionManager: ConnectionManager, private securityContext: SecurityContext) {} + + public async validate( + call: grpc.ServerUnaryCall, + callback: grpc.sendUnaryData + ): Promise<{ isAuthenticated: Boolean; conn: Connection | undefined }> { + const metadata = call.metadata.getMap(); + const jweToken = metadata.jwetoken as string; + const clientAddress = call.getPeer(); + let conn = this.connectionManager.getConnectionByAddress(clientAddress, ConnectionDirection.RECEIVING); + const peerCert = GRPCAuthInterceptor.getPeerCertFromCall(call); + + // Check if token exists + if (!jweToken) { + return this.alertAndFail(call, callback, conn, grpc.status.UNAUTHENTICATED, 'No token provided', TokenAuthReason.NO_TOKEN, { + peer: clientAddress, + peerSN: GRPCAuthInterceptor.normalizeSerial(peerCert?.serialNumber), + token: jweToken, + method: String(call.request?.method || 'POST').toUpperCase(), + url: call.request?.path, + status: grpc.status.UNAUTHENTICATED, + addr: clientAddress, + ms: Date.now(), + }); + } + + // Check if connection exists + if (conn) { + // Check if connection is blocked + if (conn.status === ConnectionStatus.BLOCKED) { + return this.alertAndFail(call, callback, conn, grpc.status.UNAUTHENTICATED, 'No token provided', TokenAuthReason.NO_TOKEN, { + peer: clientAddress, + peerSN: GRPCAuthInterceptor.normalizeSerial(peerCert?.serialNumber), + token: jweToken, + method: String(call.request?.method || 'POST').toUpperCase(), + url: call.request?.path, + status: grpc.status.UNAUTHENTICATED, + addr: clientAddress, + ms: Date.now(), + }); + } + + if (conn.token === jweToken) { + // check for allowed requests and serial number match if token is the same + const isReqAllowed = GRPCAuthInterceptor.isRequestAllowed(conn.cachedTokenPayload, call.request); + if (!isReqAllowed.isAllowed) { + return this.alertAndFail( + call, + callback, + conn, + grpc.status.PERMISSION_DENIED, + 'Method not allowed', + isReqAllowed.isUnexpired ? TokenAuthReason.PERMISSION_FORBIDDEN : TokenAuthReason.PERMISSION_EXPIRED, + { + method: String(call.request?.method || 'POST').toUpperCase(), + peer: clientAddress, + peerSN: GRPCAuthInterceptor.normalizeSerial(peerCert?.serialNumber), + token: jweToken, + url: call.request?.path, + status: grpc.status.UNAUTHENTICATED, + addr: clientAddress, + ms: Date.now(), + } + ); + } + + if (!GRPCAuthInterceptor.isSerialNumberMatching(conn.cachedTokenPayload, peerCert)) { + conn.handleFailedAuth(); + return this.alertAndFail( + call, + callback, + conn, + grpc.status.UNAUTHENTICATED, + 'Serial number mismatch', + TokenAuthReason.SERIAL_MISMATCH, + { + method: String(call.request?.method || 'POST').toUpperCase(), + peer: clientAddress, + peerSN: GRPCAuthInterceptor.normalizeSerial(peerCert?.serialNumber), + tokenSN: GRPCAuthInterceptor.normalizeSerial(conn.cachedTokenPayload?.sub), + token: jweToken, + url: call.request?.path, + status: grpc.status.UNAUTHENTICATED, + addr: clientAddress, + ms: Date.now(), + } + ); + } + + return { isAuthenticated: true, conn }; + } + } else { + conn = await this.connectionManager.createNewConnection(clientAddress, ConnectionDirection.RECEIVING, jweToken); + } + + // New connection - need to authenticate + // JWE decryption (RSA-OAEP-256) -> JWS (Plaintext) + let privateKey: any; + let jwsToken: string; + try { + // Importing RSA private key for decryption + privateKey = await importPKCS8(this.securityContext.clientPrivateKey.toString('utf-8'), 'RSA-OAEP-256'); + + const { plaintext } = await compactDecrypt(jweToken, privateKey); + jwsToken = new TextDecoder().decode(plaintext).trim(); + } catch (e: any) { + return this.alertAndFail( + call, + callback, + conn, + grpc.status.UNAUTHENTICATED, + 'Incorrect token provided (JWE Decryption failed)', + TokenAuthReason.JWE_DECRYPT_FAIL, + { + method: String(call.request?.method || 'POST').toUpperCase(), + peer: clientAddress, + peerSN: GRPCAuthInterceptor.normalizeSerial(peerCert?.serialNumber), + token: jweToken, + url: call.request?.path, + status: grpc.status.UNAUTHENTICATED, + addr: clientAddress, + err: e.message, + ms: Date.now(), + } + ); + } + + // Verify JWS (With signature) and payload extraction + let pub: any; + let payload: TokenPayload; + + try { + // Convert a raw Base64 Ed25519 public key to JWK format + const jwk = { + kty: 'OKP', + crv: 'Ed25519', + x: Buffer.from(this.securityContext.JWS_PUBLIC_KEY, 'base64').toString('base64url'), + }; + + // Importing the Ed25519 public key for verification - using "EdDSA" algorithm + pub = await importJWK(jwk, 'EdDSA'); + + // Compact verify - verify with key and decode the JWS token in one step + const { payload: jwtPayload, protectedHeader } = await compactVerify(jwsToken, pub); + + // Additional check to ensure correct signing algorithm was used + if (protectedHeader.alg !== 'EdDSA' && protectedHeader.alg !== 'Ed25519') { + return this.alertAndFail( + call, + callback, + conn, + grpc.status.UNAUTHENTICATED, + 'Incorrect signing algorithm for JWS.', + TokenAuthReason.JWS_INVALID, + { + method: String(call.request?.method || 'POST').toUpperCase(), + peer: clientAddress, + peerSN: GRPCAuthInterceptor.normalizeSerial(peerCert?.serialNumber), + token: jweToken, + url: call.request?.path, + status: grpc.status.UNAUTHENTICATED, + addr: clientAddress, + err: 'Incorrect signing algorithm for JWS', + alg: protectedHeader.alg, + ms: Date.now(), + } + ); + } + + // Decode and parse the JWT payload + const payloadString = new TextDecoder().decode(jwtPayload); + payload = JSON.parse(payloadString); + } catch (e: any) { + return this.alertAndFail( + call, + callback, + conn, + grpc.status.UNAUTHENTICATED, + 'JWS Verification error: Invalid signature', + TokenAuthReason.JWS_INVALID, + { + method: String(call.request?.method || 'POST').toUpperCase(), + peer: clientAddress, + peerSN: GRPCAuthInterceptor.normalizeSerial(peerCert?.serialNumber), + token: jweToken, + url: call.request?.path, + status: grpc.status.UNAUTHENTICATED, + addr: clientAddress, + err: e.message, + ms: Date.now(), + } + ); + } + + // mTLS binding check and authorization + // Connection tunnel verification with serialNumber (mTLS SN vs Token SN) + if (!GRPCAuthInterceptor.isSerialNumberMatching(payload, peerCert)) { + conn.handleFailedAuth(); + return this.alertAndFail(call, callback, conn, grpc.status.UNAUTHENTICATED, 'Serial number mismatch', TokenAuthReason.SERIAL_MISMATCH, { + method: String(call.request?.method || 'POST').toUpperCase(), + peer: clientAddress, + peerSN: GRPCAuthInterceptor.normalizeSerial(peerCert?.serialNumber), + tokenSN: GRPCAuthInterceptor.normalizeSerial(payload?.sub), + token: jweToken, + url: call.request?.path, + status: grpc.status.UNAUTHENTICATED, + addr: clientAddress, + ms: Date.now(), + }); + } + + // Validate permission for request method (Authorization check) + const isReqAllowed = GRPCAuthInterceptor.isRequestAllowed(conn.cachedTokenPayload, call.request); + if (!isReqAllowed.isAllowed) { + return this.alertAndFail( + call, + callback, + conn, + grpc.status.PERMISSION_DENIED, + 'Method not allowed', + isReqAllowed.isUnexpired ? TokenAuthReason.PERMISSION_FORBIDDEN : TokenAuthReason.PERMISSION_EXPIRED, + { + method: String(call.request?.method || 'POST').toUpperCase(), + peer: clientAddress, + peerSN: GRPCAuthInterceptor.normalizeSerial(peerCert?.serialNumber), + token: jweToken, + url: call.request?.path, + status: grpc.status.UNAUTHENTICATED, + addr: clientAddress, + exp: (conn.cachedTokenPayload as TokenPayload)?.exp?.[String(call.request?.method || 'POST').toUpperCase()], + ms: Date.now(), + } + ); + } + + // Authentication and Authorization successful + // Update Connection state with SN and status + conn.handleSuccessfulAuth(payload); + return { isAuthenticated: true, conn }; + } + + /** + * @description Checks if the request method is allowed based on the token permissions. + * @param tokenPayload payload extracted from the token + * @param request gRPC request object containing method information + * @param callback callback to return gRPC error if needed + * @returns true if request method is allowed, false otherwise + */ + public static isRequestAllowed(tokenPayload: TokenPayload | undefined, request: any): { isAllowed: boolean; isUnexpired: boolean } { + const method = String(request?.method || 'POST').toUpperCase(); + const isValidPayload = GRPCAuthInterceptor.validateTokenPayload(tokenPayload, request.method); + let isUnexpired = true; + + if (isValidPayload) { + isUnexpired = GRPCAuthInterceptor.isPermissionUnexpired( + (tokenPayload as TokenPayload).iat[method], + (tokenPayload as TokenPayload).exp[method] + ); + } + + if (!isValidPayload || !isUnexpired) { + return { isAllowed: false, isUnexpired: isUnexpired }; + } + + return { isAllowed: true, isUnexpired: isUnexpired }; + } + + /** + * @description Validates the structure and types of the token payload. + * @returns true if token payload is valid, false otherwise + */ + private static validateTokenPayload(tokenPayload: TokenPayload | undefined, method: string): tokenPayload is TokenPayload { + if (!tokenPayload) { + return false; + } + + if ( + typeof tokenPayload.iat !== 'object' || + typeof tokenPayload.exp !== 'object' || + typeof tokenPayload.sub !== 'string' || + typeof tokenPayload.aud !== 'string' || + typeof tokenPayload.iss !== 'string' || + typeof tokenPayload.jti !== 'string' || + Object.keys(tokenPayload.iat).length === 0 || + Object.keys(tokenPayload.exp).length === 0 || + !tokenPayload.iat.hasOwnProperty(method) || + !tokenPayload.exp.hasOwnProperty(method) + ) { + return false; + } + + return true; + } + + /** + * @description Checks if the permissions granted in the token have expired. + * @param iat issued-at timestamp for the specific method + * @param exp expiration timestamp for the specific method + * @returns true if permission is still valid, false if expired + */ + public static isPermissionUnexpired(iat: number, exp: number): boolean { + const nowInSeconds = Math.floor(Date.now() / 1000); + + if (nowInSeconds >= exp) { + return false; + } + + if (iat > nowInSeconds) { + return false; + } + + return true; + } + + /** + * @description Checks if the serial number from the peer certificate matches the one in the token payload. + * @param tokenPayload payload extracted from the token + * @param peerCert certificate object retrieved from the gRPC call + * @param callback callback to return gRPC error if needed + * @returns true if serial numbers match, false otherwise + */ + public static isSerialNumberMatching(tokenPayload: TokenPayload | undefined, peerCert: any): Boolean { + const clientSN = GRPCAuthInterceptor.normalizeSerial(peerCert?.serialNumber); + const tokenSN = GRPCAuthInterceptor.normalizeSerial(tokenPayload?.sub); + + if (!clientSN || clientSN !== tokenSN) { + return false; + } + return true; + } + + /** + * @description Normalizes a certificate serial number by removing colons and converting to uppercase. + * @param sn serial number string possibly containing colons or being null/undefined + * @returns normalized serial number string + */ + private static normalizeSerial(sn?: string | null): string { + // Node retrieves serial number as hex string, without leading 0x and with possible colons so we need to normalize it + return (sn || '').replace(/[^0-9a-f]/gi, '').toUpperCase(); + } + + /** + * @description Retrieves the peer certificate from the gRPC call object. + * @param call gRPC call object + * @returns peer certificate object from the gRPC call + */ + public static getPeerCertFromCall(call: any) { + const session = call?.call?.stream?.session; + const sock = session?.socket as any; + return sock?.getPeerCertificate(true); // whole certificate info from TLS socket + } + + /** + * Prosty helper: wyślij alert do centrali i jednocześnie zwróć błąd gRPC. + * Używa mapowania TokenAuthReason -> AlertCode, ale możesz podać własny override przez ctx. + */ + private alertAndFail( + call: grpc.ServerUnaryCall, + callback: grpc.sendUnaryData, + conn: Connection | undefined, + code: grpc.status, + msg: string, + reason: TokenAuthReason, + ctx: AlertContext = {} + ) { + const alertCode: AlertCode = codeFromTokenReason(reason); + this.connectionManager.sendCentralAlert(makeAlert(alertCode, ctx)); + + const md = new grpc.Metadata(); + md.set(TOKEN_REASON_HEADER, reason); + md.set(TOKEN_TARGET_HEADER, call.getPeer() || ''); + const err = Object.assign(new Error(msg), { code, metadata: md }); + callback(err, null); + + return { isAuthenticated: false, conn }; + } +} diff --git a/Tokenization/backend/wrapper/src/client/gRPCWrapper.ts b/Tokenization/backend/wrapper/src/client/gRPCWrapper.ts index 0622afe48..00ad926b6 100644 --- a/Tokenization/backend/wrapper/src/client/gRPCWrapper.ts +++ b/Tokenization/backend/wrapper/src/client/gRPCWrapper.ts @@ -15,11 +15,16 @@ import { ConnectionManager } from './connectionManager/ConnectionManager'; import { RevokeTokenHandler } from './commands/revokeToken/revokeToken.handler'; import { ConnectionDirection, DuplexMessageEvent } from '../models/message.model'; +import { Connection } from './connection/Connection'; import { NewTokenHandler } from './commands/newToken/newToken.handler'; -import type { Connection } from './connection/Connection'; +import { gRPCWrapperConfig } from '../models/config.model'; +import { SecurityContext } from '../utils/security/SecurityContext'; +import * as fs from 'fs'; +import { SendAllTokensHandler } from './commands/sendAllTokens/sendAllTokens.handler'; +import { LogManager } from '@aliceo2/web-ui'; /** - * @description Wrapper class for managing secure gRPC wrapper. + * Wrapper class for managing secure gRPC wrapper. * * @remarks * This class serves as a high-level abstraction over the underlying @@ -29,20 +34,51 @@ import type { Connection } from './connection/Connection'; * @example * ```typescript * const grpcWrapper = new gRPCWrapper(PROTO_PATH, CENTRAL_SYSTEM_ADDRESS); - * // Use grpcWrapper to interact with gRPC services + * Use grpcWrapper to interact with gRPC services * ``` */ export class gRPCWrapper { private _connectionManager: ConnectionManager; + private _listenerKey?: NonSharedBuffer; + private _listenerCert?: NonSharedBuffer; + private _logger = LogManager.getLogger('gRPCWrapper'); + private _securityContext: SecurityContext; /** * Initializes an instance of gRPCWrapper class. * + * @param config - External configuration object containing necessary paths and addresses. * @param protoPath - The file path to the gRPC proto definition. * @param centralAddress - The address of the central gRPC server (default: "localhost:4100"). */ - constructor(protoPath: string, centralAddress: string = 'localhost:4100') { - this._connectionManager = new ConnectionManager(protoPath, centralAddress); + constructor(config: gRPCWrapperConfig) { + if ( + !config.protoPath || + !config.centralAddress || + !config.clientCerts?.caCertPath || + !config.clientCerts?.certPath || + !config.clientCerts?.publicKeyPath || + !config.clientCerts?.privateKeyPath + ) { + throw new Error('Invalid gRPCWrapper configuration provided. Missing required paths.'); + } + + let clientListenerCert: Buffer = Buffer.alloc(0); + + // Sender keys and certs are mandatory + const caCert = fs.readFileSync(config.clientCerts.caCertPath); + const clientSenderCert = fs.readFileSync(config.clientCerts.certPath); + const clientPublicKey = fs.readFileSync(config.clientCerts.publicKeyPath); + const clientPrivateKey = fs.readFileSync(config.clientCerts.privateKeyPath); + + if (config.listenerCertPath) { + // If we have dedicated listener cert, use it + clientListenerCert = fs.readFileSync(config.listenerCertPath); + } + + this._securityContext = new SecurityContext(caCert, clientSenderCert, clientPrivateKey, clientPublicKey, clientListenerCert); + + this._connectionManager = new ConnectionManager(config.protoPath, config.centralAddress, this._securityContext); this._connectionManager.registerCommandHandlers([ { event: DuplexMessageEvent.MESSAGE_EVENT_REVOKE_TOKEN, @@ -52,6 +88,10 @@ export class gRPCWrapper { event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, handler: new NewTokenHandler(this._connectionManager), }, + { + event: DuplexMessageEvent.MESSAGE_EVENT_SEND_ALL_TOKENS, + handler: new SendAllTokensHandler(this._connectionManager), + }, ]); } @@ -63,6 +103,7 @@ export class gRPCWrapper { */ public connectToCentralSystem() { this._connectionManager.connectToCentralSystem(); + this._connectionManager.getAllTokens(); } /** diff --git a/Tokenization/backend/wrapper/src/models/alert.model.ts b/Tokenization/backend/wrapper/src/models/alert.model.ts new file mode 100644 index 000000000..28f3418aa --- /dev/null +++ b/Tokenization/backend/wrapper/src/models/alert.model.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { TokenAuthReason } from "./token.model"; + +// ====================================== +// ENUMS +// ====================================== + +export enum AlertLevel { + INFO = "INFO", + WARNING = "WARNING", + ERROR = "ERROR", + CRITICAL = "CRITICAL", +} + +/** + * Enumeration of alert codes used throughout the application to represent + * various authentication and peer-to-peer (P2P) forwarding errors. + * + * @remarks + * These codes are typically used for error handling, logging, and user notifications. + * + * @enum {string} + * @property {string} AUTH_NO_TOKEN - Indicates that no authentication token was provided. + * @property {string} AUTH_JWE_DECRYPT_FAIL - Indicates failure to decrypt a JWE (JSON Web Encryption) token. + * @property {string} AUTH_JWS_INVALID - Indicates that the JWS (JSON Web Signature) token is invalid. + * @property {string} AUTH_SN_MISMATCH - Indicates a mismatch in the serial number during authentication. + * @property {string} AUTH_METHOD_FORBIDDEN - Indicates that the authentication method is forbidden. + * @property {string} AUTH_PERMISSION_EXPIRED - Indicates that the user's permission has expired. + * @property {string} AUTH_CONN_BLOCKED - Indicates that the connection is blocked due to authentication issues. + * @property {string} P2P_FORWARD_ERROR - Indicates an error occurred during P2P forwarding. + */ +export enum AlertCode { + AUTH_NO_TOKEN = "AUTH_NO_TOKEN", + AUTH_JWE_DECRYPT_FAIL = "AUTH_JWE_DECRYPT_FAIL", + AUTH_JWS_INVALID = "AUTH_JWS_INVALID", + AUTH_SN_MISMATCH = "AUTH_SN_MISMATCH", + AUTH_METHOD_FORBIDDEN = "AUTH_METHOD_FORBIDDEN", + AUTH_PERMISSION_EXPIRED = "AUTH_PERMISSION_EXPIRED", + AUTH_CONN_BLOCKED = "AUTH_CONN_BLOCKED", + P2P_FORWARD_ERROR = "P2P_FORWARD_ERROR", +} + +// ====================================== +// INTERFACES +// ====================================== + +export interface AlertPayload { + alert: string; + level: AlertLevel; + code: AlertCode; + ts: number; + context: AlertContext; +} + +/** + * Partial context for alerts. + * + * @property peer the peer's address (e.g. hostname, IP address) + * @property err the error message (if applicable) + * @property alg the algorithm used (if applicable) + * @property peerSN the peer's certificate serial number (if applicable) + * @property token the token itself (if applicable) + * @property tokenSN the serial number bound to the token (if applicable) + * @property method the method that triggered the alert (if applicable) + * @property exp the expiration timestamp of the permission (if applicable) + * @property ms the timestamp of the event (if applicable) + * @property url the URL of the request (if applicable) + * @property status the status code of the request (if applicable) + * @property addr the address of the request (if applicable) + * @property iat the issued-at timestamp of the permission (if applicable) + * @property now the timestamp of the current moment (if applicable) + */ +export type AlertContext = Partial<{ + peer: string; + err: string; + alg: string; + peerSN: string; + token: string; + tokenSN: string; + method: string; + exp: number | string; + ms: number; + url: string; + status: number | string; + addr: string; +}>; + +// ====================================== +// CODE -> LEVEL MAP +// ====================================== + +export const AlertCodeLevel: Record = { + [AlertCode.AUTH_NO_TOKEN]: AlertLevel.ERROR, + [AlertCode.AUTH_JWE_DECRYPT_FAIL]: AlertLevel.ERROR, + [AlertCode.AUTH_JWS_INVALID]: AlertLevel.ERROR, + [AlertCode.AUTH_SN_MISMATCH]: AlertLevel.CRITICAL, + [AlertCode.AUTH_METHOD_FORBIDDEN]: AlertLevel.ERROR, + [AlertCode.AUTH_PERMISSION_EXPIRED]: AlertLevel.WARNING, + [AlertCode.AUTH_CONN_BLOCKED]: AlertLevel.CRITICAL, + [AlertCode.P2P_FORWARD_ERROR]: AlertLevel.ERROR, +}; + +// ====================================== +// HELPERS FOR ALERT CONTENT +// ====================================== + +/** + * Returns a fallback string if the given value is undefined, null, or empty string. + * Otherwise, returns the value as a string. + * @param v the value to be checked + * @param fallback the fallback string to return if v is undefined/null/empty string + * @returns the value as a string, or the fallback string if applicable + */ +const def = (v: any, fallback = "n/a") => + v === undefined || v === null || v === "" ? fallback : String(v); + +export const AlertTemplates: Record string> = { + [AlertCode.AUTH_NO_TOKEN]: (c) => `No token provided (peer=${def(c?.peer)})`, + + [AlertCode.AUTH_JWE_DECRYPT_FAIL]: (c) => + `JWE decryption failed: ${def(c?.err)}`, + + [AlertCode.AUTH_JWS_INVALID]: (c) => + `JWS verification failed: ${def(c?.err)}${c?.alg ? `, alg=${c.alg}` : ""}`, + + [AlertCode.AUTH_SN_MISMATCH]: (c) => + `Serial mismatch: peerSN=${def(c?.peerSN)}, tokenSN=${def(c?.tokenSN)}`, + + [AlertCode.AUTH_METHOD_FORBIDDEN]: (c) => + `Method ${def(c?.method)} not allowed by token policy`, + + [AlertCode.AUTH_PERMISSION_EXPIRED]: (c) => + `Permission for ${def(c?.method)} expired at ${def(c?.exp)}`, + + [AlertCode.AUTH_CONN_BLOCKED]: (c) => + `Connection blocked (peer=${def(c?.peer)})`, + + [AlertCode.P2P_FORWARD_ERROR]: (c) => + `Forwarding to ${def(c?.url)} failed: status=${def(c?.status)}, err=${def( + c?.err + )}`, +}; + +// ====================================== +// GENERATOR +// ====================================== + +/** + * Generates an AlertPayload object based on the given AlertCode and context. + * @param code the AlertCode to generate the alert for + * @param ctx the context object containing additional information for the alert + * @returns an AlertPayload object containing the generated alert message, level, code, + * timestamp, and context + */ +export function makeAlert( + code: AlertCode, + ctx: AlertContext = {} +): AlertPayload { + const level = AlertCodeLevel[code]; + const template = AlertTemplates[code] ?? (() => `Event: ${code}`); + const message = `[${code}] ${template(ctx)}`; + return { alert: message, level, code, ts: Date.now(), context: ctx }; +} +/** + * Maps a TokenAuthReason to an AlertCode. + * @param reason the reason for the token validation failure + * @returns the corresponding AlertCode + */ +export function codeFromTokenReason(reason: TokenAuthReason): AlertCode { + switch (reason) { + case TokenAuthReason.NO_TOKEN: + return AlertCode.AUTH_NO_TOKEN; + case TokenAuthReason.CONNECTION_BLOCKED: + return AlertCode.AUTH_CONN_BLOCKED; + case TokenAuthReason.JWE_DECRYPT_FAIL: + return AlertCode.AUTH_JWE_DECRYPT_FAIL; + case TokenAuthReason.JWS_INVALID: + return AlertCode.AUTH_JWS_INVALID; + case TokenAuthReason.SERIAL_MISMATCH: + return AlertCode.AUTH_SN_MISMATCH; + case TokenAuthReason.PERMISSION_FORBIDDEN: + return AlertCode.AUTH_METHOD_FORBIDDEN; + case TokenAuthReason.PERMISSION_EXPIRED: + return AlertCode.AUTH_PERMISSION_EXPIRED; + default: + // unknown error + return AlertCode.P2P_FORWARD_ERROR; + } +} diff --git a/Tokenization/backend/wrapper/src/models/commands.model.ts b/Tokenization/backend/wrapper/src/models/commands.model.ts index fd71fd9fb..1882c15b9 100644 --- a/Tokenization/backend/wrapper/src/models/commands.model.ts +++ b/Tokenization/backend/wrapper/src/models/commands.model.ts @@ -19,7 +19,7 @@ import type { DuplexMessageEvent } from './message.model'; */ export interface Command { event: DuplexMessageEvent; - payload: any; + payload?: any; } /** diff --git a/Tokenization/backend/wrapper/src/models/config.model.ts b/Tokenization/backend/wrapper/src/models/config.model.ts new file mode 100644 index 000000000..df5256986 --- /dev/null +++ b/Tokenization/backend/wrapper/src/models/config.model.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { CommandHandler } from "./commands.model"; +import { DuplexMessageEvent } from "./message.model"; + +export interface CentralSystemConfig { + /** Path to the proto file defining the services. */ + protoPath: string; + /** Host/IP to bind the gRPC server on. Defaults to "0.0.0.0" which is docker-friendly. */ + host?: string; + /** Port to bind. Defaults to 50051. */ + port?: number; + + /** Central TLS certificates paths. */ + serverCerts: { + caCertPath: string; + certPath: string; + keyPath: string; + }; + + commandHandlers?: { + command: DuplexMessageEvent; + handler: CommandHandler; + }[]; +} + +export interface gRPCWrapperConfig { + /** Path to the proto file defining the services. */ + protoPath: string; + /** Address of the CentralSystem server. */ + centralAddress: string; + + /** Client TLS certificates paths. */ + clientCerts: { + caCertPath: string; + publicKeyPath: string; + privateKeyPath: string; + certPath: string; + }; + + /** Optional listener TLS certificate path. If provided, the gRPCWrapper will be able to accept incoming connections. */ + listenerCertPath?: string; +} diff --git a/Tokenization/backend/wrapper/src/models/connection.model.ts b/Tokenization/backend/wrapper/src/models/connection.model.ts index eb9cb07c5..dc94b955b 100644 --- a/Tokenization/backend/wrapper/src/models/connection.model.ts +++ b/Tokenization/backend/wrapper/src/models/connection.model.ts @@ -43,10 +43,13 @@ export enum ConnectionStatus { RECONNECTING = 'RECONNECTING', // The connection is refreshing its authentication token TOKEN_REFRESH = 'TOKEN_REFRESH', + // The connection has been blocked + BLOCKED = 'BLOCKED', } export type ConnectionHeaders = Record; +// Options for making fetch-like requests over a connection export type FetchOptions = { method?: string; path?: string; @@ -54,6 +57,7 @@ export type FetchOptions = { body?: string | Buffer | Uint8Array | null; }; +// A more specific type for fetch responses, including status, headers, and body export type FetchResponse = { status: number; headers: ConnectionHeaders; diff --git a/Tokenization/backend/wrapper/src/models/message.model.ts b/Tokenization/backend/wrapper/src/models/message.model.ts index a1819ceb1..5afd36e70 100644 --- a/Tokenization/backend/wrapper/src/models/message.model.ts +++ b/Tokenization/backend/wrapper/src/models/message.model.ts @@ -12,6 +12,9 @@ * or submit itself to any jurisdiction. */ +import { AlertPayload } from './alert.model'; +import { SingleTokenPayload, TokenListPayload } from './token.model'; + // ====================================== // ENUMS // ====================================== @@ -21,11 +24,21 @@ * @property MESSAGE_EVENT_EMPTY: No event, used for initialization or no response. * @property MESSAGE_EVENT_NEW_TOKEN: Event for replacing with newly generated token. * @property MESSAGE_EVENT_REVOKE_TOKEN: Event for revoking an existing token. + * @property MESSAGE_EVENT_GET_ALL_TOKENS: Event for getting all tokens for this client. + * @property MESSAGE_EVENT_RENEW_TOKEN: Event for renewing a token after expiration. + * @property MESSAGE_EVENT_SEND_ALERT: Event for sending an alert to the central system. */ export enum DuplexMessageEvent { + // Central system commands MESSAGE_EVENT_EMPTY = 'MESSAGE_EVENT_EMPTY', MESSAGE_EVENT_NEW_TOKEN = 'MESSAGE_EVENT_NEW_TOKEN', MESSAGE_EVENT_REVOKE_TOKEN = 'MESSAGE_EVENT_REVOKE_TOKEN', + MESSAGE_EVENT_SEND_ALL_TOKENS = 'MESSAGE_EVENT_SEND_ALL_TOKENS', + + // Client commands + MESSAGE_EVENT_GET_ALL_TOKENS = 'MESSAGE_EVENT_GET_LAST_TOKEN', + MESSAGE_EVENT_RENEW_TOKEN = 'MESSAGE_EVENT_RENEW_TOKEN', + MESSAGE_EVENT_SEND_ALERT = 'MESSAGE_EVENT_SEND_ALERT', } /** @@ -35,7 +48,6 @@ export enum DuplexMessageEvent { * @property DUPLEX: Indicates a connection that can both send and receive messages. */ export enum ConnectionDirection { - UNSPECIFIED = 'UNSPECIFIED', SENDING = 'SENDING', RECEIVING = 'RECEIVING', DUPLEX = 'DUPLEX', @@ -45,31 +57,19 @@ export enum ConnectionDirection { // INTERFACES // ====================================== -/** - * @description Model for token generation and revocation messages. - * @property {string} token - The token to be replaced or revoked. - * @property {string} targetAddress - The address of connection binded to this token. - */ -export interface TokenMessage { - token?: string; - connectionDirection: ConnectionDirection; - targetAddress: string; -} +export type PayloadVariant = TokenListPayload | SingleTokenPayload | AlertPayload; /** * @description Model for duplex stream messages between client and central system. * @property {DuplexMessageEvent} event - The event type of the message. - * @property {ConnectionDirection} connectionDirection - The direction of the connection, optional for some events. - * @property {TokenMessage} data - The data associated with the event, it may be undefined for some events. + * @property {PayloadVariant} payload - The data associated with the event, it may be undefined for some events. * @example * { * event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, - * connectionDirection: ConnectionDirection.SENDING, - * payload: {token: 'abc', targetAddress: 'localhost:50051'} + * payload: { singleToken: {token: 'abc', targetAddress: 'localhost:50051'} } * } */ export interface DuplexMessageModel { event: DuplexMessageEvent; - newToken?: TokenMessage; - revokeToken?: TokenMessage; + payload?: PayloadVariant; } diff --git a/Tokenization/backend/wrapper/src/models/token.model.ts b/Tokenization/backend/wrapper/src/models/token.model.ts new file mode 100644 index 000000000..872dde442 --- /dev/null +++ b/Tokenization/backend/wrapper/src/models/token.model.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { ConnectionDirection } from "./message.model"; + +// ====================================== +// ENUMS +// ====================================== + +/** + * @remarks This enum is used to indicate specific causes when token-based authentication does not succeed. + * + * @enum + * @property NO_TOKEN - No authentication token was provided. + * @property CONNECTION_BLOCKED - The connection was blocked, possibly due to security policies. + * @property JWE_DECRYPT_FAIL - Failed to decrypt the JWE (JSON Web Encryption) token. + * @property JWS_INVALID - The JWS (JSON Web Signature) token is invalid. + * @property SERIAL_MISMATCH - The token's serial number does not match the expected value. + * @property PERMISSION_EXPIRED - The permissions associated with the token have expired. + * @property PERMISSION_FORBIDDEN - The token does not have the required permissions. + */ +export enum TokenAuthReason { + NO_TOKEN = "NO_TOKEN", + CONNECTION_BLOCKED = "CONNECTION_BLOCKED", + JWE_DECRYPT_FAIL = "JWE_DECRYPT_FAIL", + JWS_INVALID = "JWS_INVALID", + SERIAL_MISMATCH = "SERIAL_MISMATCH", + PERMISSION_EXPIRED = "PERMISSION_EXPIRED", + PERMISSION_FORBIDDEN = "PERMISSION_FORBIDDEN", +} + +// Header names for token messages +export const TOKEN_REASON_HEADER = "x-token-reason"; // TokenAuthReason from enum +export const TOKEN_TARGET_HEADER = "x-token-target"; // address/peer + +// ====================================== +// INTERFACES +// ====================================== + +/** + * @description Model for token generation and revocation messages. + * @property {string} token - The token to be replaced or revoked. + * @property {ConnectionDirection} connectionDirection - The direction of the connection associated with this token. + * @property {string} targetAddress - The address of connection binded to this token. + */ +export interface TokenMessage { + token?: string; + connectionDirection?: ConnectionDirection; + targetAddress: string; +} + +export interface TokenListPayload { + tokensList: TokenMessage[]; +} + +export interface SingleTokenPayload { + singleToken: TokenMessage; +} + +/** + * @description Payload structure for authentication tokens + * @sub {string} sub - Subject: Client's certificate serial number + * @aud {string} aud - Audience: Listener's certificate serial number + * @iss {string} iss - Issuer: Central system's certificate serial number + * @iat {Object} iat - Issued At: Permissions granted to the client (e.g., allowed HTTP methods with timestamps) + * @exp {number} exp - Expiration: Expiry timestamps for the granted permissions + * @jti {string} jti - JWT ID: Unique identifier for the token + */ +export type TokenPayload = { + sub: string; + aud: string; + iss: string; + iat: { [method: string]: number }; + exp: { [method: string]: number }; + jti: string; +}; diff --git a/Tokenization/backend/wrapper/src/test/central/CentralSystemWrapper.test.ts b/Tokenization/backend/wrapper/src/test/central/CentralSystemWrapper.test.ts index 4c7476cbb..9b5be6277 100644 --- a/Tokenization/backend/wrapper/src/test/central/CentralSystemWrapper.test.ts +++ b/Tokenization/backend/wrapper/src/test/central/CentralSystemWrapper.test.ts @@ -21,6 +21,7 @@ const mockServerInstance = { const logger = { infoMessage: jest.fn(), + errorMessage: jest.fn(), }; jest.mock( @@ -45,7 +46,7 @@ jest.mock('@grpc/grpc-js', () => { ...original, Server: jest.fn(() => mockServerInstance), ServerCredentials: { - createInsecure: jest.fn(() => 'mock-credentials'), + createSsl: jest.fn(() => 'mock-credentials'), }, loadPackageDefinition: jest.fn(() => ({ webui: { @@ -61,21 +62,26 @@ jest.mock('@grpc/grpc-js', () => { import { CentralSystemWrapper } from '../../central/CentralSystemWrapper'; import * as grpc from '@grpc/grpc-js'; +import { getTestCentralCertPaths } from '../testCerts/testCerts'; describe('CentralSystemWrapper', () => { let wrapper: CentralSystemWrapper; + const testCentralCertPaths = getTestCentralCertPaths(); beforeEach(() => { jest.clearAllMocks(); - wrapper = new CentralSystemWrapper('dummy.proto', 12345); + wrapper = new CentralSystemWrapper({ + protoPath: 'dummy.proto', + port: 12345, + serverCerts: testCentralCertPaths, + }); }); test('should set up gRPC service and add it to the server', () => { - const testWrapper = new CentralSystemWrapper('dummy.proto', 12345); expect(grpc.Server).toHaveBeenCalled(); expect(grpc.loadPackageDefinition).toHaveBeenCalled(); - expect(grpc.ServerCredentials.createInsecure).not.toHaveBeenCalled(); - expect(testWrapper).toBeDefined(); + expect(grpc.ServerCredentials.createSsl).not.toHaveBeenCalled(); + expect(wrapper).toBeDefined(); }); test('should call listen and bind the server', () => { @@ -92,7 +98,7 @@ describe('CentralSystemWrapper', () => { wrapper.listen(); - expect(logger.infoMessage).toHaveBeenCalledWith('Server bind error:', error); + expect(logger.errorMessage).toHaveBeenCalledWith('Server bind error:', error); }); test('should handle client stream events', () => { @@ -118,6 +124,6 @@ describe('CentralSystemWrapper', () => { expect(logger.infoMessage).toHaveBeenCalledWith(expect.stringContaining('Client client123')); expect(logger.infoMessage).toHaveBeenCalledWith('Client client123 ended stream.'); - expect(logger.infoMessage).toHaveBeenCalledWith('Stream error from client client123:', expect.any(Error)); + expect(logger.errorMessage).toHaveBeenCalledWith('Stream error from client client123:', expect.any(Error)); }); }); diff --git a/Tokenization/backend/wrapper/src/test/client/Connection/Connection.test.ts b/Tokenization/backend/wrapper/src/test/client/Connection/Connection.test.ts new file mode 100644 index 000000000..6f7e74559 --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/client/Connection/Connection.test.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { Connection } from '../../../client/connection/Connection'; +import { ConnectionDirection } from '../../../models/message.model'; +import { ConnectionStatus } from '../../../models/connection.model'; +import { TOKEN_REASON_HEADER, TokenAuthReason, TokenPayload } from '../../../models/token.model'; + +jest.mock( + '@aliceo2/web-ui', + () => ({ + LogManager: { + getLogger: jest.fn(() => ({ + warnMessage: jest.fn(), + errorMessage: jest.fn(), + })), + }, + }), + { virtual: true } +); + +const mockRenewToken = jest.fn(); + +describe('Connection', () => { + const jweToken = 'test-token'; + const targetAddress = 'localhost:50051'; + const direction = ConnectionDirection.SENDING; + + let connection: Connection; + + beforeEach(() => { + connection = new Connection(jweToken, targetAddress, direction, mockRenewToken, 'serial-123'); + mockRenewToken.mockClear(); + }); + + it('should initialize with correct values', () => { + expect(connection.token).toBe(jweToken); + expect(connection.targetAddress).toBe(targetAddress); + expect(connection.status).toBe(ConnectionStatus.CONNECTED); + expect(connection.serialNumber).toBe('serial-123'); + expect(typeof connection.lastActiveTimestamp).toBe('number'); + }); + + it('should update and get serial number', () => { + connection.serialNumber = 'new-serial'; + expect(connection.serialNumber).toBe('new-serial'); + }); + + it('should handle new token', () => { + connection.handleNewToken('new-token'); + expect(connection.token).toBe('new-token'); + expect(connection.status).toBe(ConnectionStatus.CONNECTED); + }); + + it('should handle token revocation', () => { + connection.handleRevokeToken(); + expect(connection.token).toBe(''); + expect(connection.status).toBe(ConnectionStatus.UNAUTHORIZED); + }); + + it('should handle successful authentication', () => { + const payload: TokenPayload = { + sub: 'user', + exp: Date.now() + 1000, + } as any; + connection.handleSuccessfulAuth(payload); + expect(connection.cachedTokenPayload).toBe(payload); + expect(connection.status).toBe(ConnectionStatus.CONNECTED); + expect(typeof connection.lastActiveTimestamp).toBe('number'); + }); + + it('should increment auth failures and block after 5 failures', () => { + for (let i = 0; i < 4; i++) { + expect(connection.handleFailedAuth()).toBe(i + 1); + expect(connection.status).toBe(ConnectionStatus.CONNECTED); + } + expect(connection.handleFailedAuth()).toBe(5); + expect(connection.status).toBe(ConnectionStatus.BLOCKED); + }); + + it('should update status', () => { + connection.status = ConnectionStatus.BLOCKED; + expect(connection.status).toBe(ConnectionStatus.BLOCKED); + }); + + it('should attach grpc client', () => { + const grpcClient = { Fetch: jest.fn() }; + connection.attachGrpcClient(grpcClient); + // peerClient is private, so we just verify the method doesn't throw + expect(connection).toBeDefined(); + }); + + describe('fetch', () => { + let grpcClient: any; + + beforeEach(() => { + grpcClient = { + Fetch: jest.fn(), + }; + connection.attachGrpcClient(grpcClient); + connection.status = ConnectionStatus.CONNECTED; + }); + + it('should throw if peerClient is not attached', async () => { + const c = new Connection(jweToken, targetAddress, direction, mockRenewToken); + await expect(c.fetch()).rejects.toThrow(/Peer client not attached/); + }); + + it('should throw if connection is blocked', async () => { + connection.status = ConnectionStatus.BLOCKED; + await expect(connection.fetch()).rejects.toThrow(/Connection is blocked/); + }); + + it('should perform a successful fetch', async () => { + grpcClient.Fetch.mockImplementation((_req: any, _meta: any, cb: any) => { + cb(null, { + status: 200, + headers: { foo: 'bar' }, + body: Buffer.from('ok'), + }); + }); + const res = await connection.fetch({ method: 'GET', path: '/test' }); + expect(res.status).toBe(200); + expect(res.headers.foo).toBe('bar'); + expect(await res.text()).toBe('ok'); + }); + + it('should handle token renewal and retry fetch', async () => { + // Simulate first fetch fails with renewable error, then succeeds + let callCount = 0; + grpcClient.Fetch.mockImplementation((_req: any, meta: any, cb: any) => { + callCount++; + if (callCount === 1) { + const err: any = new Error('Token expired'); + err.metadataMap = new Map([[TOKEN_REASON_HEADER, TokenAuthReason.PERMISSION_EXPIRED]]); + cb(err); + } else { + cb(null, { status: 200, headers: {}, body: Buffer.from('retry-ok') }); + } + }); + + // Patch handleNewToken to simulate token refresh + setTimeout(() => { + connection.handleNewToken('refreshed-token'); + }, 10); + + const res = await connection.fetch({ method: 'POST', path: '/renew' }); + expect(res.status).toBe(200); + expect(await res.text()).toBe('retry-ok'); + expect(mockRenewToken).toHaveBeenCalled(); + }); + + it('should throw on non-renewable error', async () => { + grpcClient.Fetch.mockImplementation((_req: any, _meta: any, cb: any) => { + const err: any = new Error('Forbidden'); + err.metadataMap = new Map([[TOKEN_REASON_HEADER, TokenAuthReason.PERMISSION_FORBIDDEN]]); + cb(err); + }); + await expect(connection.fetch({ path: '/fail' })).rejects.toThrow('Forbidden'); + }); + }); + + describe('createSslTunnel', () => { + const peerCtor = jest.fn(); + const certs = { + caCert: Buffer.from('ca'), + clientCert: Buffer.from('cert'), + clientKey: Buffer.from('key'), + }; + + it('should throw if certs are missing', () => { + expect(() => + connection.createSslTunnel(peerCtor, { + caCert: Buffer.from('ca'), + clientCert: undefined as any, + clientKey: Buffer.from('key'), + }) + ).toThrow(/Connection certificates are required/); + }); + + it('should create ssl tunnel and set status', () => { + const grpcCreds = {}; + const oldCreateSsl = jest.spyOn(require('@grpc/grpc-js').credentials, 'createSsl').mockReturnValue(grpcCreds as any); + peerCtor.mockImplementation(() => ({})); + connection.createSslTunnel(peerCtor, certs); + expect(peerCtor).toHaveBeenCalledWith(targetAddress, grpcCreds); + expect(connection.status).toBe(ConnectionStatus.CONNECTED); + oldCreateSsl.mockRestore(); + }); + }); +}); diff --git a/Tokenization/backend/wrapper/src/test/client/ConnectionManager/CentralConnection.test.ts b/Tokenization/backend/wrapper/src/test/client/ConnectionManager/CentralConnection.test.ts new file mode 100644 index 000000000..3ebde0d89 --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/client/ConnectionManager/CentralConnection.test.ts @@ -0,0 +1,299 @@ +import { CentralConnection } from '../../../client/connectionManager/CentralConnection'; +import { DuplexMessageEvent } from '../../../models/message.model'; + +const mockLogger = { + infoMessage: jest.fn(), + debugMessage: jest.fn(), + warnMessage: jest.fn(), + errorMessage: jest.fn(), +}; + +jest.mock( + '@aliceo2/web-ui', + () => ({ + LogManager: { + getLogger: jest.fn(() => mockLogger), + }, + }), + { virtual: true } +); + +jest.mock('../../../utils/connection/reconnectionScheduler', () => ({ + ReconnectionScheduler: jest.fn().mockImplementation((callback) => ({ + reset: jest.fn(), + schedule: jest.fn(), + _callback: callback, + })), +})); + +describe('CentralConnection', () => { + let centralConnection: CentralConnection; + let mockClient: any; + let mockDispatcher: any; + let mockStream: any; + const CENTRAL_ADDRESS = 'localhost:50051'; + + beforeEach(() => { + jest.clearAllMocks(); + + mockStream = { + on: jest.fn(), + write: jest.fn(), + end: jest.fn(), + }; + + mockClient = { + ClientStream: jest.fn(() => mockStream), + }; + + mockDispatcher = { + dispatch: jest.fn(), + }; + + centralConnection = new CentralConnection(mockClient, mockDispatcher, CENTRAL_ADDRESS); + }); + + describe('constructor', () => { + it('should initialize with correct central address', () => { + expect(centralConnection.centralAddress).toBe(CENTRAL_ADDRESS); + }); + + it('should create reconnection scheduler', () => { + const { ReconnectionScheduler } = require('../../../utils/connection/reconnectionScheduler'); + expect(ReconnectionScheduler).toHaveBeenCalled(); + }); + }); + + describe('connect', () => { + it('should create stream and set up event listeners', () => { + centralConnection.connect(); + + expect(mockClient.ClientStream).toHaveBeenCalled(); + expect(mockStream.on).toHaveBeenCalledWith('data', expect.any(Function)); + expect(mockStream.on).toHaveBeenCalledWith('end', expect.any(Function)); + expect(mockStream.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + it('should not create new stream if already connected', () => { + centralConnection.connect(); + centralConnection.connect(); + + expect(mockClient.ClientStream).toHaveBeenCalledTimes(1); + }); + + it('should dispatch received data to dispatcher', () => { + centralConnection.connect(); + + const onDataHandler = mockStream.on.mock.calls.find(([event]: any[]) => event === 'data')?.[1]; + const testPayload = { + event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, + payload: { singleToken: { token: 'test-token', targetAddress: 'addr' } }, + }; + + onDataHandler(testPayload); + + expect(mockDispatcher.dispatch).toHaveBeenCalledWith(testPayload); + }); + + it('should reset reconnection scheduler on data received', () => { + centralConnection.connect(); + + const onDataHandler = mockStream.on.mock.calls.find(([event]: any[]) => event === 'data')?.[1]; + const testPayload = { + event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, + payload: {}, + }; + + onDataHandler(testPayload); + + const { ReconnectionScheduler } = require('../../../utils/connection/reconnectionScheduler'); + const schedulerInstance = ReconnectionScheduler.mock.results[0].value; + expect(schedulerInstance.reset).toHaveBeenCalled(); + }); + + it('should schedule reconnection on stream end', () => { + centralConnection.connect(); + + const onEndHandler = mockStream.on.mock.calls.find(([event]: any[]) => event === 'end')?.[1]; + onEndHandler(); + + const { ReconnectionScheduler } = require('../../../utils/connection/reconnectionScheduler'); + const schedulerInstance = ReconnectionScheduler.mock.results[0].value; + expect(schedulerInstance.schedule).toHaveBeenCalled(); + }); + + it('should clear stream reference on end', () => { + centralConnection.connect(); + + const onEndHandler = mockStream.on.mock.calls.find(([event]: any[]) => event === 'end')?.[1]; + onEndHandler(); + + centralConnection.connect(); + expect(mockClient.ClientStream).toHaveBeenCalledTimes(2); + }); + + it('should schedule reconnection on stream error', () => { + centralConnection.connect(); + + const onErrorHandler = mockStream.on.mock.calls.find(([event]: any[]) => event === 'error')?.[1]; + const testError = new Error('Connection error'); + onErrorHandler(testError); + + const { ReconnectionScheduler } = require('../../../utils/connection/reconnectionScheduler'); + const schedulerInstance = ReconnectionScheduler.mock.results[0].value; + expect(schedulerInstance.schedule).toHaveBeenCalled(); + }); + + it('should clear stream reference on error', () => { + centralConnection.connect(); + + const onErrorHandler = mockStream.on.mock.calls.find(([event]: any[]) => event === 'error')?.[1]; + onErrorHandler(new Error('Test error')); + + centralConnection.connect(); + expect(mockClient.ClientStream).toHaveBeenCalledTimes(2); + }); + }); + + describe('sendEvent', () => { + it('should send event when stream is connected', () => { + centralConnection.connect(); + + const testData = { + event: DuplexMessageEvent.MESSAGE_EVENT_GET_ALL_TOKENS, + }; + + const result = centralConnection.sendEvent(testData); + + expect(mockStream.write).toHaveBeenCalledWith(testData); + expect(result).toBe(true); + }); + + it('should return false when stream is not connected', () => { + const testData = { + event: DuplexMessageEvent.MESSAGE_EVENT_GET_ALL_TOKENS, + }; + + const result = centralConnection.sendEvent(testData); + + expect(result).toBe(false); + expect(mockLogger.warnMessage).toHaveBeenCalledWith(expect.stringContaining('Stream is not defined')); + }); + + it('should handle write errors gracefully', () => { + centralConnection.connect(); + + mockStream.write.mockImplementation(() => { + throw new Error('Write error'); + }); + + const testData = { + event: DuplexMessageEvent.MESSAGE_EVENT_GET_ALL_TOKENS, + }; + + const result = centralConnection.sendEvent(testData); + + expect(result).toBe(false); + expect(mockLogger.errorMessage).toHaveBeenCalledWith(expect.stringContaining('Error sending'), expect.any(Error)); + }); + + it('should send multiple events successfully', () => { + centralConnection.connect(); + + const event1 = { + event: DuplexMessageEvent.MESSAGE_EVENT_GET_ALL_TOKENS, + }; + + const event2 = { + event: DuplexMessageEvent.MESSAGE_EVENT_RENEW_TOKEN, + payload: { singleToken: { token: 'token', targetAddress: 'addr' } }, + }; + + centralConnection.sendEvent(event1); + centralConnection.sendEvent(event2); + + expect(mockStream.write).toHaveBeenCalledTimes(2); + expect(mockStream.write).toHaveBeenNthCalledWith(1, event1); + expect(mockStream.write).toHaveBeenNthCalledWith(2, event2); + }); + }); + + describe('start', () => { + it('should connect and log connection message', () => { + centralConnection.start(); + + expect(mockClient.ClientStream).toHaveBeenCalled(); + expect(mockLogger.infoMessage).toHaveBeenCalledWith(expect.stringContaining(`Connected to CentralSystem on ${CENTRAL_ADDRESS}`)); + }); + + it('should not create duplicate stream if already connected', () => { + centralConnection.connect(); + centralConnection.start(); + + expect(mockClient.ClientStream).toHaveBeenCalledTimes(1); + }); + }); + + describe('disconnect', () => { + it('should end stream when connected', () => { + centralConnection.connect(); + centralConnection.disconnect(); + + expect(mockStream.end).toHaveBeenCalled(); + expect(mockLogger.infoMessage).toHaveBeenCalledWith(expect.stringContaining('Disconnected from CentralSystem')); + }); + + it('should handle disconnect when not connected', () => { + centralConnection.disconnect(); + + expect(mockStream.end).not.toHaveBeenCalled(); + expect(mockLogger.infoMessage).toHaveBeenCalledWith(expect.stringContaining('Disconnected from CentralSystem')); + }); + + it('should allow reconnection after disconnect', () => { + centralConnection.connect(); + centralConnection.disconnect(); + centralConnection.connect(); + + expect(mockClient.ClientStream).toHaveBeenCalledTimes(2); + }); + }); + + describe('reconnection flow', () => { + it('should reconnect after stream end event', () => { + jest.useFakeTimers(); + centralConnection.connect(); + + const onEndHandler = mockStream.on.mock.calls.find(([event]: any[]) => event === 'end')?.[1]; + onEndHandler(); + + const { ReconnectionScheduler } = require('../../../utils/connection/reconnectionScheduler'); + const schedulerInstance = ReconnectionScheduler.mock.results[0].value; + const reconnectCallback = schedulerInstance._callback; + + reconnectCallback(); + + expect(mockClient.ClientStream).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + it('should reconnect after stream error event', () => { + jest.useFakeTimers(); + centralConnection.connect(); + + const onErrorHandler = mockStream.on.mock.calls.find(([event]: any[]) => event === 'error')?.[1]; + onErrorHandler(new Error('Network error')); + + const { ReconnectionScheduler } = require('../../../utils/connection/reconnectionScheduler'); + const schedulerInstance = ReconnectionScheduler.mock.results[0].value; + const reconnectCallback = schedulerInstance._callback; + + reconnectCallback(); + + expect(mockClient.ClientStream).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + }); +}); + +// Made with Bob diff --git a/Tokenization/backend/wrapper/src/test/client/ConnectionManager/ConnectionManager.test.ts b/Tokenization/backend/wrapper/src/test/client/ConnectionManager/ConnectionManager.test.ts new file mode 100644 index 000000000..bf4169c0d --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/client/ConnectionManager/ConnectionManager.test.ts @@ -0,0 +1,499 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import * as grpc from '@grpc/grpc-js'; +import { ConnectionManager } from '../../../client/connectionManager/ConnectionManager'; +import { ConnectionDirection, DuplexMessageEvent } from '../../../models/message.model'; +import { SecurityContext } from '../../../utils/security/SecurityContext'; + +// Mock duplex stream +const mockStream = { + on: jest.fn(), + end: jest.fn(), +}; + +// Mock gRPC client +const mockClient = { + ClientStream: jest.fn(() => mockStream), +}; + +// Mock CentralSystem constructor +const CentralSystemMock = jest.fn(() => mockClient); + +// Mock gRPC auth interceptor +jest.mock('../../../client/connectionManager/interceptors/grpc.auth.interceptor', () => ({ + GRPCAuthInterceptor: jest.fn().mockImplementation(() => ({ + validate: jest.fn().mockResolvedValue({ + isAuthenticated: true, + conn: { + updateStatus: jest.fn(), + handleSuccessfulAuth: jest.fn(), + getSerialNumber: jest.fn(), + setSerialNumber: jest.fn(), + }, + }), + })), +})); + +// Mock dispatcher +const mockDispatch = jest.fn(); +jest.mock('../../../client/connectionManager/eventManagement/CentralCommandDispatcher', () => ({ + CentralCommandDispatcher: jest.fn(() => ({ + dispatch: mockDispatch, + register: jest.fn(), + })), +})); + +// Mock logger +jest.mock( + '@aliceo2/web-ui', + () => ({ + LogManager: { + getLogger: () => ({ + infoMessage: jest.fn(), + debugMessage: jest.fn(), + errorMessage: jest.fn(), + }), + }, + }), + { virtual: true } +); + +// Mock gRPC proto loader and client +jest.mock('@grpc/proto-loader', () => ({ + loadSync: jest.fn(() => { + return {}; + }), +})); + +let capturedServerImpl: any | null = null; + +jest.mock('@grpc/grpc-js', () => { + const original = jest.requireActual('@grpc/grpc-js'); + const Peer2PeerMock: any = jest.fn(() => ({ + Fetch: jest.fn(), + })); + // simulation of the service definition + Peer2PeerMock.service = { + Fetch: { + path: '/webui.tokenization.Peer2Peer/Fetch', + requestStream: false, + responseStream: false, + requestSerialize: (x: any) => x, + requestDeserialize: (x: any) => x, + responseSerialize: (x: any) => x, + responseDeserialize: (x: any) => x, + }, + }; + + // Mock server + const mockServer = { + addService: jest.fn((_svc: any, impl: any) => { + capturedServerImpl = impl; + }), + bindAsync: jest.fn((_addr: any, _creds: any, cb: any) => cb(null)), + forceShutdown: jest.fn(), + }; + + const mockServerCtor = jest.fn(() => mockServer); + + return { + ...original, + credentials: { + createSsl: jest.fn(() => 'mock-credentials'), + }, + ServerCredentials: { + createSsl: jest.fn(() => 'mock-credentials'), + }, + status: { + ...original.status, + INTERNAL: 13, + }, + loadPackageDefinition: jest.fn(() => ({ + webui: { + tokenization: { + CentralSystem: CentralSystemMock, + Peer2Peer: Peer2PeerMock, + }, + }, + })), + Server: mockServerCtor, + }; +}); + +describe('ConnectionManager', () => { + let conn: ConnectionManager; + const MOCK_CERT = Buffer.from('MOCK_CERT'); + const securityContext = new SecurityContext(MOCK_CERT, MOCK_CERT, MOCK_CERT, MOCK_CERT, MOCK_CERT); + + beforeEach(() => { + jest.clearAllMocks(); + capturedServerImpl = null; + global.fetch = jest.fn(); + conn = new ConnectionManager('dummy.proto', 'localhost:12345', securityContext); + }); + + afterAll(() => { + // @ts-ignore + delete global.fetch; + }); + + test('should initialize client with correct address', () => { + expect(conn).toBeDefined(); + expect(grpc.loadPackageDefinition).toHaveBeenCalled(); + expect(CentralSystemMock).toHaveBeenCalledWith('localhost:12345', 'mock-credentials'); + }); + + test('connectToCentralSystem() should set up stream listeners', () => { + conn.connectToCentralSystem(); + + expect(mockClient.ClientStream).toHaveBeenCalled(); + expect(mockStream.on).toHaveBeenCalledWith('data', expect.any(Function)); + expect(mockStream.on).toHaveBeenCalledWith('end', expect.any(Function)); + expect(mockStream.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + test('disconnectFromCentralSystem() should end stream', () => { + conn.connectToCentralSystem(); + conn.disconnectFromCentralSystem(); + + expect(mockStream.end).toHaveBeenCalled(); + }); + + test("should reconnect on stream 'end'", () => { + jest.useFakeTimers(); + conn.connectToCentralSystem(); + const onEnd = mockStream.on.mock.calls.find(([event]) => event === 'end')?.[1]; + + onEnd?.(); // simulate 'end' + jest.advanceTimersByTime(2000); + + expect(mockClient.ClientStream).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + test("should reconnect on stream 'error'", () => { + jest.useFakeTimers(); + conn.connectToCentralSystem(); + const onError = mockStream.on.mock.calls.find(([event]) => event === 'error')?.[1]; + + onError?.(new Error('Simulated error')); + jest.advanceTimersByTime(2000); + + expect(mockClient.ClientStream).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); + + test("should dispatch event when 'data' is received", () => { + conn.connectToCentralSystem(); + const onData = mockStream.on.mock.calls.find(([event]) => event === 'data')?.[1]; + + const mockMessage = { + event: DuplexMessageEvent.MESSAGE_EVENT_REVOKE_TOKEN, + data: { + revokeToken: { + token: 'abc123', + targetAddress: 'peer-123', + }, + }, + }; + + onData?.(mockMessage); + + expect(mockDispatch).toHaveBeenCalledWith(mockMessage); + }); + + test('listenForPeers() should start server and register service', async () => { + await conn.listenForPeers(50055, 'http://localhost:40041/api/'); + + const serverCtor = (grpc.Server as any).mock; + expect(serverCtor).toBeDefined(); + expect(serverCtor.calls.length).toBeGreaterThan(0); + + const serverInstance = serverCtor.results[0].value; + expect(serverInstance.addService).toHaveBeenCalled(); + expect(serverInstance.bindAsync).toHaveBeenCalledWith('localhost:50055', expect.anything(), expect.any(Function)); + + expect(capturedServerImpl).toBeTruthy(); + expect(typeof capturedServerImpl.Fetch).toBe('function'); + }); + + test('p2p Fetch should register incoming receiving connection and forward request', async () => { + await conn.listenForPeers(50056, 'http://localhost:40041/api/'); + + // prepare data to call + const call = { + getPeer: () => 'client-42', + request: { + method: 'POST', + path: 'echo', + headers: { 'content-type': 'application/json' }, + body: Buffer.from(JSON.stringify({ ping: true })), + }, + } as any; + + const callback = jest.fn(); + + // @ts-ignore - mock global.fetch response + global.fetch.mockResolvedValue({ + status: 202, + headers: { + forEach: (fn: (v: string, k: string) => void) => { + fn('application/json', 'content-type'); + fn('test', 'x-extra'); + }, + }, + arrayBuffer: async () => Buffer.from(JSON.stringify({ ok: 1 })), + }); + + const before = conn.connections.receiving.length; + await capturedServerImpl.Fetch(call, callback); + + expect(global.fetch).toHaveBeenCalledWith('http://localhost:40041/api/echo', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ ping: true }), + }); + + // callback with response from forwarded fetch + expect(callback).toHaveBeenCalledWith(null, { + status: 202, + headers: { 'content-type': 'application/json', 'x-extra': 'test' }, + body: expect.any(Buffer), + }); + + // connection receiving should be registered + const after = conn.connections.receiving.length; + expect(after).toBeGreaterThan(before); + + const rec = conn.getConnectionByAddress('client-42', ConnectionDirection.RECEIVING); + expect(rec).toBeDefined(); + }); + + test('p2p Fetch should return INTERNAL on forward error', async () => { + await conn.listenForPeers(50057, 'http://localhost:40041/api/'); + + const call = { + getPeer: () => 'client-error', + request: { + method: 'GET', + path: 'fail', + headers: {}, + }, + } as any; + + const callback = jest.fn(); + + // @ts-ignore + global.fetch.mockRejectedValue(new Error('err')); + + await capturedServerImpl.Fetch(call, callback); + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + code: grpc.status.INTERNAL, + message: 'err', + }) + ); + }); + + describe('createNewConnection', () => { + test('should create new SENDING connection', async () => { + const connection = await conn.createNewConnection('localhost:5000', ConnectionDirection.SENDING, 'test-token'); + + expect(connection).toBeDefined(); + expect(connection.targetAddress).toBe('localhost:5000'); + expect(connection.direction).toBe(ConnectionDirection.SENDING); + }); + + test('should create new RECEIVING connection', async () => { + const connection = await conn.createNewConnection('localhost:5001', ConnectionDirection.RECEIVING, 'test-token'); + + expect(connection).toBeDefined(); + expect(connection.targetAddress).toBe('localhost:5001'); + expect(connection.direction).toBe(ConnectionDirection.RECEIVING); + }); + + test('should return existing connection and update token', async () => { + const conn1 = await conn.createNewConnection('localhost:5002', ConnectionDirection.SENDING, 'token1'); + const conn2 = await conn.createNewConnection('localhost:5002', ConnectionDirection.SENDING, 'token2'); + + expect(conn1).toBe(conn2); + }); + + test('should create connection without token', async () => { + const connection = await conn.createNewConnection('localhost:5003', ConnectionDirection.SENDING); + + expect(connection).toBeDefined(); + expect(connection.token).toBe(''); + }); + }); + + describe('getConnectionByAddress', () => { + test('should return SENDING connection', async () => { + await conn.createNewConnection('localhost:6000', ConnectionDirection.SENDING, 'token'); + const found = conn.getConnectionByAddress('localhost:6000', ConnectionDirection.SENDING); + + expect(found).toBeDefined(); + expect(found?.targetAddress).toBe('localhost:6000'); + }); + + test('should return RECEIVING connection', async () => { + await conn.createNewConnection('localhost:6001', ConnectionDirection.RECEIVING, 'token'); + const found = conn.getConnectionByAddress('localhost:6001', ConnectionDirection.RECEIVING); + + expect(found).toBeDefined(); + expect(found?.targetAddress).toBe('localhost:6001'); + }); + + test('should return undefined for non-existent connection', () => { + const found = conn.getConnectionByAddress('localhost:9999', ConnectionDirection.SENDING); + + expect(found).toBeUndefined(); + }); + + test('should return undefined for invalid direction', () => { + const found = conn.getConnectionByAddress('localhost:6000', 'INVALID' as ConnectionDirection); + + expect(found).toBeUndefined(); + }); + }); + + describe('getConnectionByToken', () => { + test('should find connection by token in sending connections', async () => { + await conn.createNewConnection('localhost:7000', ConnectionDirection.SENDING, 'unique-token-1'); + const found = conn.getConnectionByToken('unique-token-1'); + + expect(found).toBeDefined(); + expect(found?.targetAddress).toBe('localhost:7000'); + }); + + test('should find connection by token in receiving connections', async () => { + await conn.createNewConnection('localhost:7001', ConnectionDirection.RECEIVING, 'unique-token-2'); + const found = conn.getConnectionByToken('unique-token-2'); + + expect(found).toBeDefined(); + expect(found?.targetAddress).toBe('localhost:7001'); + }); + + test('should return undefined for non-existent token', () => { + const found = conn.getConnectionByToken('non-existent-token'); + + expect(found).toBeUndefined(); + }); + }); + + describe('getConnectionBySerialNumber', () => { + test('should find connection by serial number in receiving connections', async () => { + const connection = await conn.createNewConnection('localhost:8000', ConnectionDirection.RECEIVING, 'token'); + connection.serialNumber = 'SN123456'; + + const found = conn.getConnectionBySerialNumber('SN123456'); + + expect(found).toBeDefined(); + expect(found?.serialNumber).toBe('SN123456'); + }); + + test('should find connection by serial number in sending connections', async () => { + const connection = await conn.createNewConnection('localhost:8001', ConnectionDirection.SENDING, 'token'); + connection.serialNumber = 'SN789012'; + + const found = conn.getConnectionBySerialNumber('SN789012'); + + expect(found).toBeDefined(); + expect(found?.serialNumber).toBe('SN789012'); + }); + + test('should return undefined for non-existent serial number', () => { + const found = conn.getConnectionBySerialNumber('NON-EXISTENT'); + + expect(found).toBeUndefined(); + }); + }); + + describe('connections getter', () => { + test('should return all connections', async () => { + await conn.createNewConnection('localhost:9000', ConnectionDirection.SENDING, 'token1'); + await conn.createNewConnection('localhost:9001', ConnectionDirection.RECEIVING, 'token2'); + + const connections = conn.connections; + + expect(connections.sending.length).toBeGreaterThanOrEqual(1); + expect(connections.receiving.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('registerCommandHandlers', () => { + test('should register command handlers', () => { + const mockHandler = { handle: jest.fn() }; + + conn.registerCommandHandlers([{ event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, handler: mockHandler }]); + + expect(mockHandler).toBeDefined(); + }); + }); + + describe('getAllTokens', () => { + test('should send getAllTokens event to central system', () => { + conn.connectToCentralSystem(); + conn.getAllTokens(); + + expect(mockClient.ClientStream).toHaveBeenCalled(); + }); + }); + + describe('listenForPeers error handling', () => { + test('should throw error for invalid port number', async () => { + await expect(conn.listenForPeers(0)).rejects.toThrow('Invalid port number'); + }); + + test('should throw error for port number too high', async () => { + await expect(conn.listenForPeers(70000)).rejects.toThrow('Invalid port number'); + }); + + test('should throw error for negative port number', async () => { + await expect(conn.listenForPeers(-1)).rejects.toThrow('Invalid port number'); + }); + + test('should throw error for non-integer port', async () => { + await expect(conn.listenForPeers(50.5)).rejects.toThrow('Invalid port number'); + }); + + test('should handle missing listener certificate', async () => { + const secCtx = new SecurityContext(MOCK_CERT, MOCK_CERT, MOCK_CERT, MOCK_CERT); + const manager = new ConnectionManager('dummy.proto', 'localhost:12345', secCtx); + + await manager.listenForPeers(50058); + + const serverCtor = (grpc.Server as any).mock; + expect(serverCtor.calls.length).toBe(0); + }); + }); + + describe('sendCentralAlert', () => { + test('should send alert to central system', () => { + conn.connectToCentralSystem(); + + conn.sendCentralAlert({ + alert: 'Test alert', + level: 'ERROR' as any, + code: 'TEST_CODE' as any, + ts: Date.now(), + context: {}, + }); + + expect(mockClient.ClientStream).toHaveBeenCalled(); + }); + }); +}); diff --git a/Tokenization/backend/wrapper/src/test/client/ConnectionManager/Interceptors/grpc.auth.interceptor.test.ts b/Tokenization/backend/wrapper/src/test/client/ConnectionManager/Interceptors/grpc.auth.interceptor.test.ts new file mode 100644 index 000000000..9a7d262df --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/client/ConnectionManager/Interceptors/grpc.auth.interceptor.test.ts @@ -0,0 +1,582 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import * as grpc from '@grpc/grpc-js'; +import * as jose from 'jose'; +import { GRPCAuthInterceptor } from '../../../../client/connectionManager/interceptors/grpc.auth.interceptor'; + +// Connection class mock +jest.mock( + '../../../../client/connection/Connection', + () => { + return { + Connection: jest.fn().mockImplementation((jweToken: string, address: string, direction: any) => { + return { + jweToken, + address, + direction, + status: 1, + payload: { subSerialNumber: 'AABBCC', perm: { POST: true } }, + getStatus: jest.fn(function () { + return this.status; + }), + getToken: jest.fn(function () { + return this.jweToken; + }), + getCachedTokenPayload: jest.fn(function () { + return this.payload; + }), + handleFailedAuth: jest.fn(), + handleSuccessfulAuth: jest.fn(function (p: any) { + this.payload = p; + this.status = 1; + }), + }; + }), + }; + }, + { virtual: true } +); + +import { Connection } from '../../../../client/connection/Connection'; + +jest.mock('jose', () => ({ + importPKCS8: jest.fn(), + importJWK: jest.fn(), + compactDecrypt: jest.fn(), + compactVerify: jest.fn(), +})); + +import { ConnectionStatus } from '../../../../models/connection.model'; +import { TokenPayload } from '../../../../models/token.model'; +import { SecurityContext } from '../../../../utils/security/SecurityContext'; +import { ConnectionDirection } from '../../../../models/message.model'; + +const mockSecurityContext = { + clientPrivateKey: Buffer.from('mock_private_key_rsa'), + JWS_PUBLIC_KEY: 'mock_public_key_ed25519', +} as unknown as SecurityContext; + +let isRequestAllowedSpy: jest.SpyInstance; +let isSerialNumberMatchingSpy: jest.SpyInstance; +let getPeerCertFromCallSpy: jest.SpyInstance; + +const mockCall = { + metadata: { getMap: jest.fn(() => ({})) }, + getPeer: jest.fn(() => 'ipv4:127.0.0.1:12345'), + request: { method: 'POST' }, +} as unknown as grpc.ServerUnaryCall; + +const mockCallback = jest.fn(); +const mockClientConnections = new Map(); +const mockConnectionManager = { + getConnectionByAddress: jest.fn((address: string) => mockClientConnections.get(address)), + createNewConnection: jest.fn(async (address: string, direction: any, token: string) => { + const conn = new (Connection as unknown as jest.Mock)(token, address, direction); + mockClientConnections.set(address, conn); + return conn; + }), + sendCentralAlert: jest.fn(), +}; + +describe('gRPCAuthInterceptor', () => { + const MOCK_ADDRESS = 'ipv4:127.0.0.1:12345'; + const VALID_JWE = 'valid.jwe.token'; + const VALID_JWS = 'valid.jws.token'; + const DECRYPTED_PAYLOAD: TokenPayload = { + subSerialNumber: 'DDEEFF', + perm: { POST: true, GET: false }, + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + mockClientConnections.clear(); + + (mockCall.metadata.getMap as unknown as jest.Mock).mockReturnValue({ + jwetoken: VALID_JWE, + }); + (mockCall.getPeer as unknown as jest.Mock).mockReturnValue(MOCK_ADDRESS); + (mockCall as any).request = { method: 'POST' }; + + (jose.importPKCS8 as jest.Mock).mockResolvedValue('mock_priv_key'); + (jose.compactDecrypt as jest.Mock).mockResolvedValue({ + plaintext: Buffer.from(VALID_JWS), + }); + (jose.importJWK as jest.Mock).mockResolvedValue('mock_pub_key'); + (jose.compactVerify as jest.Mock).mockResolvedValue({ + payload: Buffer.from(JSON.stringify(DECRYPTED_PAYLOAD)), + protectedHeader: { alg: 'EdDSA' }, + }); + + // mocks of internal functions + isRequestAllowedSpy = jest.spyOn(GRPCAuthInterceptor, 'isRequestAllowed').mockReturnValue({ isAllowed: true, isUnexpired: true }); + + isSerialNumberMatchingSpy = jest.spyOn(GRPCAuthInterceptor, 'isSerialNumberMatching').mockReturnValue(true); + + getPeerCertFromCallSpy = jest.spyOn(GRPCAuthInterceptor, 'getPeerCertFromCall').mockReturnValue({ serialNumber: 'DDEEFF' }); + }); + + const getCreatedConn = () => { + const instances = (Connection as unknown as jest.Mock).mock?.instances ?? []; + return instances.find((i: any) => i.address === MOCK_ADDRESS) ?? mockClientConnections.get(MOCK_ADDRESS); + }; + + it('should fail if no JWE token is provided in the metadata', async () => { + (mockCall.metadata.getMap as unknown as jest.Mock).mockReturnValue({}); + + const authInterceptor = new GRPCAuthInterceptor(mockConnectionManager as any, mockSecurityContext); + const result = await authInterceptor.validate(mockCall, mockCallback); + + expect(result.isAuthenticated).toBe(false); + expect(result.conn).toBeUndefined(); + expect(mockCallback).toHaveBeenCalledWith( + expect.objectContaining({ + code: grpc.status.UNAUTHENTICATED, + message: 'No token provided', + }), + null + ); + }); + + it("should authenticate instantly if connection exists and token hasn't changed", async () => { + const existingConn = new (Connection as unknown as jest.Mock)(VALID_JWE, MOCK_ADDRESS, ConnectionDirection.RECEIVING); + existingConn.getToken.mockReturnValue(VALID_JWE); + mockClientConnections.set(MOCK_ADDRESS, existingConn); + + const authInterceptor = new GRPCAuthInterceptor(mockConnectionManager as any, mockSecurityContext); + const result = await authInterceptor.validate(mockCall, mockCallback); + + expect(result.isAuthenticated).toBe(true); + expect(result.conn).toBe(existingConn); + expect(isRequestAllowedSpy).toHaveBeenCalledTimes(1); + expect(isSerialNumberMatchingSpy).toHaveBeenCalledTimes(1); + expect(jose.compactDecrypt as jest.Mock).toHaveBeenCalledTimes(1); + expect(jose.compactDecrypt as jest.Mock).toHaveBeenCalledWith(VALID_JWE, 'mock_priv_key'); + }); + + it('should reject if connection exists but is BLOCKED', async () => { + const existingConn = new (Connection as unknown as jest.Mock)(VALID_JWE, MOCK_ADDRESS, ConnectionDirection.RECEIVING); + existingConn.status = ConnectionStatus.BLOCKED; + mockClientConnections.set(MOCK_ADDRESS, existingConn); + + const authInterceptor = new GRPCAuthInterceptor(mockConnectionManager as any, mockSecurityContext); + const result = await authInterceptor.validate(mockCall, mockCallback); + + expect(result.isAuthenticated).toBe(false); + expect(mockCallback).toHaveBeenCalled(); + const callArgs = mockCallback.mock.calls[0][0]; + expect(callArgs.code).toBe(grpc.status.UNAUTHENTICATED); + }); + + it('should reject existing connection on serial number mismatch', async () => { + const existingConn = new (Connection as unknown as jest.Mock)(VALID_JWE, MOCK_ADDRESS, ConnectionDirection.RECEIVING); + existingConn.getToken.mockReturnValue(VALID_JWE); + mockClientConnections.set(MOCK_ADDRESS, existingConn); + + // mock serial number mismatch + isSerialNumberMatchingSpy.mockReturnValue(false); + + const authInterceptor = new GRPCAuthInterceptor(mockConnectionManager as any, mockSecurityContext); + const result = await authInterceptor.validate(mockCall, mockCallback); + + expect(result.isAuthenticated).toBe(false); + expect(existingConn.handleFailedAuth).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalled(); + const callArgs = mockCallback.mock.calls[0][0]; + expect(callArgs.message).toContain('Serial number mismatch'); + }); + + it('should successfully authenticate a NEW connection', async () => { + (mockCall.metadata.getMap as unknown as jest.Mock).mockReturnValue({ + jwetoken: 'NEW.JWE.TOKEN', + }); + + const authInterceptor = new GRPCAuthInterceptor(mockConnectionManager as any, mockSecurityContext); + const result = await authInterceptor.validate(mockCall, mockCallback); + + const created = getCreatedConn(); + expect(result.isAuthenticated).toBe(true); + expect(created).toBeDefined(); + expect(created!.handleSuccessfulAuth).toHaveBeenCalledWith(DECRYPTED_PAYLOAD); + expect(jose.compactDecrypt as jest.Mock).toHaveBeenCalledTimes(1); + expect(jose.compactVerify as jest.Mock).toHaveBeenCalledTimes(1); + expect(isSerialNumberMatchingSpy).toHaveBeenCalledTimes(1); + expect(isRequestAllowedSpy).toHaveBeenCalledTimes(1); + }); + + it('should fail if JWE decryption fails', async () => { + (jose.compactDecrypt as jest.Mock).mockRejectedValue(new Error('Decryption failed')); + + const authInterceptor = new GRPCAuthInterceptor(mockConnectionManager as any, mockSecurityContext); + const result = await authInterceptor.validate(mockCall, mockCallback); + + const created = getCreatedConn(); + expect(result.isAuthenticated).toBe(false); + // Connection is created but auth fails before handleFailedAuth is called + expect(created).toBeDefined(); + expect(mockCallback).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Incorrect token provided (JWE Decryption failed)', + }), + null + ); + }); + + it('should fail if JWS verification fails', async () => { + (jose.compactVerify as jest.Mock).mockRejectedValue(new Error('Invalid signature')); + + const authInterceptor = new GRPCAuthInterceptor(mockConnectionManager as any, mockSecurityContext); + const result = await authInterceptor.validate(mockCall, mockCallback); + + const created = getCreatedConn(); + expect(result.isAuthenticated).toBe(false); + // Connection is created but auth fails before handleFailedAuth is called + expect(created).toBeDefined(); + expect(mockCallback).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'JWS Verification error: Invalid signature', + }), + null + ); + }); + + it('should fail if mTLS serial number mismatch occurs after decryption', async () => { + isSerialNumberMatchingSpy.mockReturnValue(false); + + const authInterceptor = new GRPCAuthInterceptor(mockConnectionManager as any, mockSecurityContext); + const result = await authInterceptor.validate(mockCall, mockCallback); + + const created = getCreatedConn(); + expect(result.isAuthenticated).toBe(false); + expect(created!.handleFailedAuth).toHaveBeenCalledTimes(1); + expect(mockCallback).toHaveBeenCalled(); + const callArgs = mockCallback.mock.calls[0][0]; + expect(callArgs.message).toContain('Serial number mismatch'); + }); + + it('should fail if request authorization check fails', async () => { + isRequestAllowedSpy.mockReturnValue({ isAllowed: false, isUnexpired: true }); + + const authInterceptor = new GRPCAuthInterceptor(mockConnectionManager as any, mockSecurityContext); + const result = await authInterceptor.validate(mockCall, mockCallback); + + expect(result.isAuthenticated).toBe(false); + expect(mockCallback).toHaveBeenCalledWith(expect.objectContaining({ code: grpc.status.PERMISSION_DENIED }), null); + expect(isSerialNumberMatchingSpy).toHaveBeenCalledTimes(1); + }); + it('should reject if existing connection has request not allowed', async () => { + const existingConn = new (Connection as unknown as jest.Mock)(VALID_JWE, MOCK_ADDRESS, ConnectionDirection.RECEIVING); + existingConn.getToken.mockReturnValue(VALID_JWE); + mockClientConnections.set(MOCK_ADDRESS, existingConn); + + // mock request not allowed + isRequestAllowedSpy.mockReturnValue({ isAllowed: false, isUnexpired: true }); + + const authInterceptor = new GRPCAuthInterceptor(mockConnectionManager as any, mockSecurityContext); + const result = await authInterceptor.validate(mockCall, mockCallback); + + expect(result.isAuthenticated).toBe(false); + expect(result.conn).toBe(existingConn); + expect(mockCallback).toHaveBeenCalled(); + const callArgs = mockCallback.mock.calls[0][0]; + expect(callArgs.message).toContain('Method not allowed'); + }); + + it('should re-authenticate when existing connection has different token', async () => { + const existingConn = new (Connection as unknown as jest.Mock)('OLD.TOKEN', MOCK_ADDRESS, ConnectionDirection.RECEIVING); + existingConn.getToken.mockReturnValue('OLD.TOKEN'); + mockClientConnections.set(MOCK_ADDRESS, existingConn); + + (mockCall.metadata.getMap as unknown as jest.Mock).mockReturnValue({ + jwetoken: 'NEW.TOKEN', + }); + + const authInterceptor = new GRPCAuthInterceptor(mockConnectionManager as any, mockSecurityContext); + const result = await authInterceptor.validate(mockCall, mockCallback); + + expect(result.isAuthenticated).toBe(true); + expect(existingConn.handleSuccessfulAuth).toHaveBeenCalledWith(DECRYPTED_PAYLOAD); + expect(jose.compactDecrypt as jest.Mock).toHaveBeenCalledTimes(1); + expect(jose.compactVerify as jest.Mock).toHaveBeenCalledTimes(1); + }); + + it('should fail if JWS has incorrect signing algorithm', async () => { + (jose.compactVerify as jest.Mock).mockResolvedValue({ + payload: Buffer.from(JSON.stringify(DECRYPTED_PAYLOAD)), + protectedHeader: { alg: 'RS256' }, // Wrong algorithm + }); + + const authInterceptor = new GRPCAuthInterceptor(mockConnectionManager as any, mockSecurityContext); + const result = await authInterceptor.validate(mockCall, mockCallback); + + expect(result.isAuthenticated).toBe(false); + expect(mockCallback).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Incorrect signing algorithm for JWS.', + code: grpc.status.UNAUTHENTICATED, + }), + null + ); + }); +}); + +describe('isRequestAllowed', () => { + + beforeEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('should return true for valid payload with unexpired permission', () => { + const now = Math.floor(Date.now() / 1000); + const payload: TokenPayload = { + sub: 'AABBCC', + aud: 'test-audience', + iss: 'test-issuer', + jti: 'test-jti', + iat: { POST: now - 100 }, + exp: { POST: now + 3600 }, + } as any; + + const request = { method: 'POST' }; + const result = GRPCAuthInterceptor.isRequestAllowed(payload, request); + + expect(result.isAllowed).toBe(true); + expect(result.isUnexpired).toBe(true); + }); + + it('should return false for expired permission', () => { + const now = Math.floor(Date.now() / 1000); + const payload: TokenPayload = { + sub: 'AABBCC', + aud: 'test-audience', + iss: 'test-issuer', + jti: 'test-jti', + iat: { POST: now - 7200 }, + exp: { POST: now - 3600 }, // Expired 1 hour ago + } as any; + + const request = { method: 'POST' }; + const result = GRPCAuthInterceptor.isRequestAllowed(payload, request); + + expect(result.isAllowed).toBe(false); + expect(result.isUnexpired).toBe(false); + }); + + it('should return false for method not in token permissions', () => { + const now = Math.floor(Date.now() / 1000); + const payload: TokenPayload = { + sub: 'AABBCC', + aud: 'test-audience', + iss: 'test-issuer', + jti: 'test-jti', + iat: { POST: now - 100 }, + exp: { POST: now + 3600 }, + } as any; + + const request = { method: 'DELETE' }; // Not in permissions + const result = GRPCAuthInterceptor.isRequestAllowed(payload, request); + + expect(result.isAllowed).toBe(false); + }); + + it('should handle missing request method with default POST', () => { + const now = Math.floor(Date.now() / 1000); + const payload: TokenPayload = { + sub: 'AABBCC', + aud: 'test-audience', + iss: 'test-issuer', + jti: 'test-jti', + iat: { POST: now - 100 }, + exp: { POST: now + 3600 }, + } as any; + + const request = { method: 'POST' }; // Explicitly set POST since default handling requires method + const result = GRPCAuthInterceptor.isRequestAllowed(payload, request); + + expect(result.isAllowed).toBe(true); + }); + + it('should return false for invalid payload structure (missing iat)', () => { + const now = Math.floor(Date.now() / 1000); + const payload: any = { + sub: 'AABBCC', + aud: 'test-audience', + iss: 'test-issuer', + jti: 'test-jti', + exp: { POST: now + 3600 }, + // iat missing + }; + + const request = { method: 'POST' }; + const result = GRPCAuthInterceptor.isRequestAllowed(payload, request); + + expect(result.isAllowed).toBe(false); + }); + + it('should return false for invalid payload structure (empty iat)', () => { + const now = Math.floor(Date.now() / 1000); + const payload: any = { + sub: 'AABBCC', + aud: 'test-audience', + iss: 'test-issuer', + jti: 'test-jti', + iat: {}, // Empty + exp: { POST: now + 3600 }, + }; + + const request = { method: 'POST' }; + const result = GRPCAuthInterceptor.isRequestAllowed(payload, request); + + expect(result.isAllowed).toBe(false); + }); +}); + +describe('isPermissionExpired', () => { + it('should return false for valid unexpired permission', () => { + const now = Math.floor(Date.now() / 1000); + const iat = now - 100; + const exp = now + 3600; + + const result = GRPCAuthInterceptor.isPermissionUnexpired(iat, exp); + + expect(result).toBe(true); + }); + + it('should return true when permission has expired', () => { + const now = Math.floor(Date.now() / 1000); + const iat = now - 7200; + const exp = now - 3600; // Expired 1 hour ago + + const result = GRPCAuthInterceptor.isPermissionUnexpired(iat, exp); + + expect(result).toBe(false); + }); + + it('should return true when iat is in the future', () => { + const now = Math.floor(Date.now() / 1000); + const iat = now + 100; // Issued in the future + const exp = now + 3600; + + const result = GRPCAuthInterceptor.isPermissionUnexpired(iat, exp); + + expect(result).toBe(false); + }); +}); + +describe('isSerialNumberMatching', () => { + const mockCallback = jest.fn(); + + beforeEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('should return true when serial numbers match', () => { + const payload: TokenPayload = { + sub: 'AABBCCDDEE', + } as any; + const peerCert = { serialNumber: 'AA:BB:CC:DD:EE' }; + + const result = GRPCAuthInterceptor.isSerialNumberMatching(payload, peerCert); + + expect(result).toBe(true); + }); + + it('should return true when serial numbers match (different formats)', () => { + const payload: TokenPayload = { + sub: 'aabbccddee', + } as any; + const peerCert = { serialNumber: 'AA:BB:CC:DD:EE' }; + + const result = GRPCAuthInterceptor.isSerialNumberMatching(payload, peerCert); + + expect(result).toBe(true); + }); + + it('should return false when serial numbers do not match', () => { + const payload: TokenPayload = { + sub: 'AABBCCDDEE', + } as any; + const peerCert = { serialNumber: '11:22:33:44:55' }; + + const result = GRPCAuthInterceptor.isSerialNumberMatching(payload, peerCert); + + expect(result).toBe(false); + }); + + it('should return false when peerCert is null', () => { + const payload: TokenPayload = { + sub: 'AABBCCDDEE', + } as any; + + const result = GRPCAuthInterceptor.isSerialNumberMatching(payload, null); + + expect(result).toBe(false); + }); + + it('should return false when payload is undefined', () => { + const peerCert = { serialNumber: 'AA:BB:CC:DD:EE' }; + + const result = GRPCAuthInterceptor.isSerialNumberMatching(undefined, peerCert); + + expect(result).toBe(false); + }); + + it('should normalize serial numbers with special characters', () => { + const payload: TokenPayload = { + sub: 'AA-BB-CC-DD-EE', + } as any; + const peerCert = { serialNumber: 'AA:BB:CC:DD:EE' }; + + const result = GRPCAuthInterceptor.isSerialNumberMatching(payload, peerCert); + + expect(result).toBe(true); + }); +}); + +describe('getPeerCertFromCall', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('should return peer certificate from call', () => { + const mockCert = { serialNumber: 'AABBCC', subject: 'CN=test' }; + const mockCall = { + call: { + stream: { + session: { + socket: { + getPeerCertificate: jest.fn().mockReturnValue(mockCert), + }, + }, + }, + }, + }; + + const result = GRPCAuthInterceptor.getPeerCertFromCall(mockCall); + + expect(result).toBe(mockCert); + expect(mockCall.call.stream.session.socket.getPeerCertificate).toHaveBeenCalledWith(true); + }); + + it('should handle missing call structure gracefully', () => { + const mockCall = {}; + + const result = GRPCAuthInterceptor.getPeerCertFromCall(mockCall); + + expect(result).toBeUndefined(); + }); +}); diff --git a/Tokenization/backend/wrapper/src/test/client/ConnectionManager/eventManagement/CentralCommandDispatcher.test.ts b/Tokenization/backend/wrapper/src/test/client/ConnectionManager/eventManagement/CentralCommandDispatcher.test.ts new file mode 100644 index 000000000..0b257d52e --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/client/ConnectionManager/eventManagement/CentralCommandDispatcher.test.ts @@ -0,0 +1,206 @@ +import { CentralCommandDispatcher } from '../../../../client/connectionManager/eventManagement/CentralCommandDispatcher'; +import { DuplexMessageEvent } from '../../../../models/message.model'; +import { Command, CommandHandler } from '../../../../models/commands.model'; + +jest.mock( + '@aliceo2/web-ui', + () => ({ + LogManager: { + getLogger: jest.fn(() => ({ + infoMessage: jest.fn(), + debugMessage: jest.fn(), + warnMessage: jest.fn(), + errorMessage: jest.fn(), + })), + }, + }), + { virtual: true } +); + +describe('CentralCommandDispatcher', () => { + let dispatcher: CentralCommandDispatcher; + let mockHandler: CommandHandler; + + beforeEach(() => { + dispatcher = new CentralCommandDispatcher(); + mockHandler = { + handle: jest.fn().mockResolvedValue(undefined), + }; + }); + + describe('register', () => { + it('should register a handler for a specific event type', () => { + dispatcher.register(DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, mockHandler); + + expect(mockHandler).toBeDefined(); + }); + + it('should register multiple handlers for different event types', () => { + const mockHandler2: CommandHandler = { + handle: jest.fn().mockResolvedValue(undefined), + }; + + dispatcher.register(DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, mockHandler); + dispatcher.register(DuplexMessageEvent.MESSAGE_EVENT_REVOKE_TOKEN, mockHandler2); + + expect(mockHandler).toBeDefined(); + expect(mockHandler2).toBeDefined(); + }); + + it('should overwrite existing handler when registering same event type', () => { + const mockHandler2: CommandHandler = { + handle: jest.fn().mockResolvedValue(undefined), + }; + + dispatcher.register(DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, mockHandler); + dispatcher.register(DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, mockHandler2); + + const command: Command = { + event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, + payload: {}, + }; + + dispatcher.dispatch(command); + + expect(mockHandler2.handle).toHaveBeenCalledWith(command); + expect(mockHandler.handle).not.toHaveBeenCalled(); + }); + }); + + describe('dispatch', () => { + it('should dispatch command to registered handler', async () => { + dispatcher.register(DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, mockHandler); + + const command: Command = { + event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, + payload: { singleToken: { token: 'test-token', targetAddress: 'localhost:5000' } }, + }; + + await dispatcher.dispatch(command); + + expect(mockHandler.handle).toHaveBeenCalledWith(command); + expect(mockHandler.handle).toHaveBeenCalledTimes(1); + }); + + it('should handle command with empty payload', async () => { + dispatcher.register(DuplexMessageEvent.MESSAGE_EVENT_GET_ALL_TOKENS, mockHandler); + + const command: Command = { + event: DuplexMessageEvent.MESSAGE_EVENT_GET_ALL_TOKENS, + payload: {}, + }; + + await dispatcher.dispatch(command); + + expect(mockHandler.handle).toHaveBeenCalledWith(command); + }); + + it('should not throw when no handler is registered for event type', async () => { + const command: Command = { + event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, + payload: {}, + }; + + await expect(dispatcher.dispatch(command)).resolves.not.toThrow(); + }); + + it('should log warning when no handler is registered', async () => { + const command: Command = { + event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, + payload: {}, + }; + + await dispatcher.dispatch(command); + + expect(mockHandler.handle).not.toHaveBeenCalled(); + }); + + it('should catch and log errors thrown by handler', async () => { + const errorHandler: CommandHandler = { + handle: jest.fn().mockRejectedValue(new Error('Handler error')), + }; + + dispatcher.register(DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, errorHandler); + + const command: Command = { + event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, + payload: {}, + }; + + await expect(dispatcher.dispatch(command)).resolves.not.toThrow(); + expect(errorHandler.handle).toHaveBeenCalledWith(command); + }); + + it('should handle multiple dispatches to same handler', async () => { + dispatcher.register(DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, mockHandler); + + const command1: Command = { + event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, + payload: { singleToken: { token: 'token1', targetAddress: 'addr1' } }, + }; + + const command2: Command = { + event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, + payload: { singleToken: { token: 'token2', targetAddress: 'addr2' } }, + }; + + await dispatcher.dispatch(command1); + await dispatcher.dispatch(command2); + + expect(mockHandler.handle).toHaveBeenCalledTimes(2); + expect(mockHandler.handle).toHaveBeenNthCalledWith(1, command1); + expect(mockHandler.handle).toHaveBeenNthCalledWith(2, command2); + }); + + it('should handle async handler execution', async () => { + const asyncHandler: CommandHandler = { + handle: jest.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }), + }; + + dispatcher.register(DuplexMessageEvent.MESSAGE_EVENT_SEND_ALL_TOKENS, asyncHandler); + + const command: Command = { + event: DuplexMessageEvent.MESSAGE_EVENT_SEND_ALL_TOKENS, + payload: { tokensList: [] }, + }; + + await dispatcher.dispatch(command); + + expect(asyncHandler.handle).toHaveBeenCalledWith(command); + }); + + it('should dispatch different commands to different handlers', async () => { + const handler1: CommandHandler = { + handle: jest.fn().mockResolvedValue(undefined), + }; + const handler2: CommandHandler = { + handle: jest.fn().mockResolvedValue(undefined), + }; + + dispatcher.register(DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, handler1); + dispatcher.register(DuplexMessageEvent.MESSAGE_EVENT_REVOKE_TOKEN, handler2); + + const command1: Command = { + event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, + payload: {}, + }; + + const command2: Command = { + event: DuplexMessageEvent.MESSAGE_EVENT_REVOKE_TOKEN, + payload: {}, + }; + + await dispatcher.dispatch(command1); + await dispatcher.dispatch(command2); + + expect(handler1.handle).toHaveBeenCalledWith(command1); + expect(handler2.handle).toHaveBeenCalledWith(command2); + expect(handler1.handle).not.toHaveBeenCalledWith(command2); + expect(handler2.handle).not.toHaveBeenCalledWith(command1); + }); + }); +}); + +// Made with Bob diff --git a/Tokenization/backend/wrapper/src/test/client/commands/newToken.test.ts b/Tokenization/backend/wrapper/src/test/client/commands/newToken.test.ts index fd5c23215..dd29c4057 100644 --- a/Tokenization/backend/wrapper/src/test/client/commands/newToken.test.ts +++ b/Tokenization/backend/wrapper/src/test/client/commands/newToken.test.ts @@ -21,20 +21,38 @@ import { ConnectionDirection, DuplexMessageEvent } from '../../../models/message import * as grpc from '@grpc/grpc-js'; import * as protoLoader from '@grpc/proto-loader'; import path from 'path'; +import { getTestCerts } from '../../testCerts/testCerts'; + +// Mock logger +jest.mock( + '@aliceo2/web-ui', + () => ({ + LogManager: { + getLogger: () => ({ + infoMessage: jest.fn(), + debugMessage: jest.fn(), + errorMessage: jest.fn(), + }), + }, + }), + { virtual: true } +); /** * Helper to create a new token command with given address, direction, and token. */ -function createEventMessage(targetAddress: string, connectionDirection: ConnectionDirection): Command { +const createEventMessage = (targetAddress: string, connectionDirection: ConnectionDirection, token: string): Command => { return { event: DuplexMessageEvent.MESSAGE_EVENT_NEW_TOKEN, payload: { - targetAddress, - connectionDirection, - token: 'test-token', + singleToken: { + targetAddress, + connectionDirection, + token, + }, }, } as Command; -} +}; describe('NewTokenHandler', () => { let manager: ConnectionManager; @@ -65,9 +83,10 @@ describe('NewTokenHandler', () => { return undefined; }), createNewConnection: jest.fn(function (this: any, address: string, dir: ConnectionDirection, token: string) { - const conn = new Connection(token, address, dir, peerCtor); + const conn = new Connection(token, address, dir, null as any); if (dir === ConnectionDirection.SENDING) { this.sendingConnections.set(address, conn); + conn.createSslTunnel(peerCtor, getTestCerts()); } else { this.receivingConnections.set(address, conn); } @@ -78,22 +97,24 @@ describe('NewTokenHandler', () => { it('should update token on existing SENDING connection', async () => { const targetAddress = 'peer-123'; - const conn = new Connection('old-token', targetAddress, ConnectionDirection.SENDING, peerCtor); + const conn = new Connection('old-token', targetAddress, ConnectionDirection.SENDING, null as any); + conn.createSslTunnel(peerCtor, getTestCerts()); + (manager as any).sendingConnections.set(targetAddress, conn); const handler = new NewTokenHandler(manager); - const command = new NewTokenCommand(createEventMessage(targetAddress, ConnectionDirection.SENDING).payload); + const command = new NewTokenCommand(createEventMessage(targetAddress, ConnectionDirection.SENDING, 'new-token').payload); await handler.handle(command); - expect(conn.token).toBe('test-token'); + expect(conn.token).toBe('new-token'); }); it('should create new RECEIVING connection if not found', async () => { const targetAddress = 'peer-456'; const handler = new NewTokenHandler(manager); - const command = new NewTokenCommand(createEventMessage(targetAddress, ConnectionDirection.RECEIVING).payload); + const command = new NewTokenCommand(createEventMessage(targetAddress, ConnectionDirection.RECEIVING, 'test-token').payload); await handler.handle(command); @@ -106,7 +127,7 @@ describe('NewTokenHandler', () => { const targetAddress = 'peer-789'; const handler = new NewTokenHandler(manager); - const command = new NewTokenCommand(createEventMessage(targetAddress, ConnectionDirection.DUPLEX).payload); + const command = new NewTokenCommand(createEventMessage(targetAddress, ConnectionDirection.DUPLEX, 'new-token').payload); await handler.handle(command); @@ -115,8 +136,8 @@ describe('NewTokenHandler', () => { expect(sendingConn).toBeDefined(); expect(receivingConn).toBeDefined(); - expect(sendingConn.token).toBe('test-token'); - expect(receivingConn.token).toBe('test-token'); + expect(sendingConn.token).toBe('new-token'); + expect(receivingConn.token).toBe('new-token'); }); it('should throw error when payload is missing required fields', async () => { @@ -128,9 +149,11 @@ describe('NewTokenHandler', () => { it('should create command with correct event and payload', () => { const payload = { - targetAddress: 'peer-000', - connectionDirection: ConnectionDirection.SENDING, - token: 'sample-token', + singleToken: { + targetAddress: 'peer-000', + connectionDirection: ConnectionDirection.SENDING, + token: 'sample-token', + }, }; const command = new NewTokenCommand(payload); diff --git a/Tokenization/backend/wrapper/src/test/client/commands/revokeToken.test.ts b/Tokenization/backend/wrapper/src/test/client/commands/revokeToken.test.ts index 3764d1724..cd200f9fd 100644 --- a/Tokenization/backend/wrapper/src/test/client/commands/revokeToken.test.ts +++ b/Tokenization/backend/wrapper/src/test/client/commands/revokeToken.test.ts @@ -22,6 +22,22 @@ import { Command } from 'models/commands.model'; import * as grpc from '@grpc/grpc-js'; import * as protoLoader from '@grpc/proto-loader'; import path from 'path'; +import { getTestCerts } from '../../testCerts/testCerts'; + +// Mock logger +jest.mock( + '@aliceo2/web-ui', + () => ({ + LogManager: { + getLogger: () => ({ + infoMessage: jest.fn(), + debugMessage: jest.fn(), + errorMessage: jest.fn(), + }), + }, + }), + { virtual: true } +); describe('RevokeToken', () => { const protoPath = path.join(__dirname, '..', '..', '..', '..', '..', 'proto', 'wrapper.proto'); @@ -37,15 +53,18 @@ describe('RevokeToken', () => { const wrapper = proto.webui.tokenization; const peerCtor = wrapper.Peer2Peer; - function createEventMessage(targetAddress: string) { + const createEventMessage = (targetAddress: string) => { return { event: DuplexMessageEvent.MESSAGE_EVENT_REVOKE_TOKEN, payload: { - targetAddress: targetAddress, - token: 'test-token', + singleToken: { + targetAddress: targetAddress, + token: 'test-token', + connectionDirection: ConnectionDirection.SENDING, + }, }, } as Command; - } + }; let manager: ConnectionManager; @@ -61,7 +80,8 @@ describe('RevokeToken', () => { it('should revoke token when connection found in sendingConnections', async () => { const targetAddress = 'peer-123'; - const conn = new Connection('valid-token', targetAddress, ConnectionDirection.SENDING, peerCtor); + const conn = new Connection('valid-token', targetAddress, ConnectionDirection.SENDING, null as any); + conn.createSslTunnel(peerCtor, getTestCerts()); (manager as any).sendingConnections!.set(targetAddress, conn); const handler = new RevokeTokenHandler(manager); @@ -75,7 +95,7 @@ describe('RevokeToken', () => { it('should revoke token when connection found in receivingConnections', async () => { const targetAddress = 'peer-456'; - const conn = new Connection('valid-token', targetAddress, ConnectionDirection.RECEIVING, peerCtor); + const conn = new Connection('valid-token', targetAddress, ConnectionDirection.RECEIVING, null as any); (manager as any).receivingConnections.set(targetAddress, conn); const handler = new RevokeTokenHandler(manager); @@ -93,7 +113,7 @@ describe('RevokeToken', () => { const command = new RevokeTokenCommand(createEventMessage(targetAddress).payload); await expect(handler.handle(command)).resolves.toBeUndefined(); - expect(manager.getConnectionByAddress).toHaveBeenCalledWith(targetAddress, undefined); + expect(manager.getConnectionByAddress).toHaveBeenCalledWith(targetAddress, 'SENDING'); }); it('should throw error when targetAddress is missing', async () => { @@ -105,7 +125,7 @@ describe('RevokeToken', () => { const handler = new RevokeTokenHandler(manager); const command = new RevokeTokenCommand(invalidMessage as any); - await expect(handler.handle(command)).rejects.toThrow('Target address is required to revoke token.'); + await expect(handler.handle(command)).rejects.toThrow('Target address and connection direction are required to revoke token.'); }); it('should create command with correct type and payload', () => { diff --git a/Tokenization/backend/wrapper/src/test/client/connectionManager/ConnectionManager.test.ts b/Tokenization/backend/wrapper/src/test/client/connectionManager/ConnectionManager.test.ts deleted file mode 100644 index dff46c2c6..000000000 --- a/Tokenization/backend/wrapper/src/test/client/connectionManager/ConnectionManager.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -/** - * @license - * Copyright 2019-2020 CERN and copyright holders of ALICE O2. - * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. - * All rights not expressly granted are reserved. - * - * This software is distributed under the terms of the GNU General Public - * License v3 (GPL Version 3), copied verbatim in the file "COPYING". - * - * In applying this license CERN does not waive the privileges and immunities - * granted to it by virtue of its status as an Intergovernmental Organization - * or submit itself to any jurisdiction. - */ - -import * as grpc from '@grpc/grpc-js'; - -// Capture service impl registered on grpc.Server.addService -let capturedServerImpl: any | null = null; - -jest.mock('@grpc/proto-loader', () => ({ - loadSync: jest.fn(() => ({})), -})); - -const CentralSystemClientMock = jest.fn(); -const Peer2PeerCtorMock = jest.fn(); - -// Mock @grpc/grpc-js -jest.mock('@grpc/grpc-js', () => { - const original = jest.requireActual('@grpc/grpc-js'); - - const mockServer = { - addService: jest.fn((_svc: any, impl: any) => { - capturedServerImpl = impl; - }), - bindAsync: jest.fn((_addr: string, _creds: any, cb: any) => cb(null)), - forceShutdown: jest.fn(), - }; - const ServerCtor = jest.fn(() => mockServer); - - const loadPackageDefinition = jest.fn(() => ({ - webui: { - tokenization: { - CentralSystem: CentralSystemClientMock, - Peer2Peer: Object.assign(Peer2PeerCtorMock, { - service: { - Fetch: { - path: '/webui.tokenization.Peer2Peer/Fetch', - requestStream: false, - responseStream: false, - requestSerialize: (x: any) => x, - requestDeserialize: (x: any) => x, - responseSerialize: (x: any) => x, - responseDeserialize: (x: any) => x, - }, - }, - }), - }, - }, - })); - - return { - ...original, - loadPackageDefinition, - credentials: { - createInsecure: jest.fn(() => ({})), - }, - ServerCredentials: { - createInsecure: jest.fn(() => ({})), - }, - status: { - ...original.status, - INTERNAL: 13, - }, - Server: ServerCtor, - }; -}); - -// Mock CentralCommandDispatcher -const dispatcherRegisterMock = jest.fn(); -jest.mock( - '../../../client/connectionManager/eventManagement/CentralCommandDispatcher', - () => ({ - CentralCommandDispatcher: jest.fn().mockImplementation(() => ({ - register: dispatcherRegisterMock, - })), - }), - { virtual: true } -); - -// Mock CentralConnection -const centralStartMock = jest.fn(); -const centralDisconnectMock = jest.fn(); -jest.mock( - '../../../client/connectionManager/CentralConnection', - () => ({ - CentralConnection: jest.fn().mockImplementation(() => ({ - start: centralStartMock, - disconnect: centralDisconnectMock, - })), - }), - { virtual: true } -); - -// Track Connection instances and allow status changes -const createdConnections: any[] = []; -const connectionCtorMock = jest.fn().mockImplementation(function (this: any, token: string, address: string, direction: any, peerCtor: any) { - this._token = token; - this._address = address; - this.direction = direction; - this.status = undefined; - this.targetAddress = address; - this.token = token; - Object.defineProperty(this, 'status', { - get: () => this._status, - set: (v) => (this._status = v), - configurable: true, - }); - createdConnections.push({ token, address, direction, peerCtor, instance: this }); -}); -jest.mock( - '../../../client/connection/Connection', - () => ({ - Connection: connectionCtorMock, - }), - { virtual: true } -); - -const infoMessageMock = jest.fn(); -const errorMessageMock = jest.fn(); -jest.mock( - '@aliceo2/web-ui', - () => ({ - LogManager: { - getLogger: () => ({ - infoMessage: infoMessageMock, - errorMessage: errorMessageMock, - debugMessage: jest.fn(), - }), - }, - }), - { virtual: true } -); - -import { ConnectionManager } from '../../../client/connectionManager/ConnectionManager'; -import { ConnectionDirection } from '../../../models/message.model'; -import { ConnectionStatus } from '../../../models/connection.model'; - -describe('ConnectionManager', () => { - beforeEach(() => { - jest.clearAllMocks(); - capturedServerImpl = null; - createdConnections.length = 0; - // @ts-ignore - global.fetch = jest.fn(); - }); - - afterAll(() => { - // @ts-ignore - delete global.fetch; - }); - - test('constructor: loads proto, builds wrapper/peerCtor and CentralSystem client', () => { - const cm = new ConnectionManager('proto/file.proto', 'central:5555'); - expect(cm).toBeDefined(); - - expect((grpc as any).loadPackageDefinition).toHaveBeenCalled(); - expect(CentralSystemClientMock).toHaveBeenCalledWith('central:5555', expect.any(Object)); - expect(grpc.credentials.createInsecure).toHaveBeenCalled(); - }); - - test('registerCommandHandlers: calls dispatcher.register for each item', () => { - const cm = new ConnectionManager('p.proto', 'c:1'); - dispatcherRegisterMock.mockClear(); - - const handlers = [ - { event: 1 as any, handler: { handle: jest.fn() } as any }, - { event: 2 as any, handler: { handle: jest.fn() } as any }, - ]; - - cm.registerCommandHandlers(handlers); - - expect(dispatcherRegisterMock).toHaveBeenCalledTimes(2); - expect(dispatcherRegisterMock).toHaveBeenCalledWith(handlers[0].event, handlers[0].handler); - expect(dispatcherRegisterMock).toHaveBeenCalledWith(handlers[1].event, handlers[1].handler); - }); - - test('connectToCentralSystem/disconnectFromCentralSystem delegate to CentralConnection', () => { - const cm = new ConnectionManager('p.proto', 'c:1'); - // @ts-ignore - cm['_peerCtor'] = Peer2PeerCtorMock; - cm.connectToCentralSystem(); - expect(centralStartMock).toHaveBeenCalled(); - - cm.disconnectFromCentralSystem(); - expect(centralDisconnectMock).toHaveBeenCalled(); - }); - - test('createNewConnection: adds to sending map, sets CONNECTED, logs', () => { - const cm = new ConnectionManager('p.proto', 'c:1'); - // @ts-ignore - cm['_peerCtor'] = Peer2PeerCtorMock; - const conn = cm.createNewConnection('peer-A', ConnectionDirection.SENDING, 'tok123'); - - expect(connectionCtorMock).toHaveBeenCalledWith('tok123', 'peer-A', ConnectionDirection.SENDING, expect.any(Function)); - expect(conn.status).toBe(ConnectionStatus.CONNECTED); - - // Exposed via connections getter - const { sending, receiving } = cm.connections; - expect(sending.length).toBe(1); - expect(receiving.length).toBe(0); - - expect(infoMessageMock).toHaveBeenCalledWith(expect.stringContaining('Connection with peer-A has been estabilished')); - }); - - test('createNewConnection: adds to receiving map if direction is RECEIVING', () => { - const cm = new ConnectionManager('p.proto', 'c:1'); - cm.createNewConnection('peer-B', ConnectionDirection.RECEIVING); - - const { sending, receiving } = cm.connections; - expect(sending.length).toBe(0); - expect(receiving.length).toBe(1); - }); - - test('getConnectionByAddress: returns by direction. Logs on invalid direction', () => { - const cm = new ConnectionManager('p.proto', 'c:1'); - const s = cm.createNewConnection('s-1', ConnectionDirection.SENDING); - const r = cm.createNewConnection('r-1', ConnectionDirection.RECEIVING); - - expect(cm.getConnectionByAddress('s-1', ConnectionDirection.SENDING)).toBe(s); - expect(cm.getConnectionByAddress('r-1', ConnectionDirection.RECEIVING)).toBe(r); - - errorMessageMock.mockClear(); - const invalid = cm.getConnectionByAddress('x', 999 as any); - expect(invalid).toBeUndefined(); - expect(errorMessageMock).toHaveBeenCalledWith('Invalid connection direction: 999'); - }); - - test('connections getter: returns arrays (copies) of maps', () => { - const cm = new ConnectionManager('p.proto', 'c:1'); - cm.createNewConnection('a', ConnectionDirection.SENDING); - cm.createNewConnection('b', ConnectionDirection.RECEIVING); - - const { sending, receiving } = cm.connections; - expect(Array.isArray(sending)).toBe(true); - expect(Array.isArray(receiving)).toBe(true); - expect(sending.length).toBe(1); - expect(receiving.length).toBe(1); - }); - - test('listenForPeers: creates server, registers service, binds & logs', async () => { - const cm = new ConnectionManager('p.proto', 'c:1'); - await cm.listenForPeers(50099, 'http://localhost:41000/api/'); - - const ServerCtor = (grpc.Server as any).mock; - expect(ServerCtor).toBeDefined(); - expect(ServerCtor.calls.length).toBeGreaterThan(0); - - const serverInstance = ServerCtor.results[0].value; - expect(serverInstance.addService).toHaveBeenCalled(); - expect(serverInstance.bindAsync).toHaveBeenCalledWith('localhost:50099', expect.anything(), expect.any(Function)); - expect(infoMessageMock).toHaveBeenCalledWith('Peer server listening on localhost:50099'); - - // Service impl captured - expect(capturedServerImpl).toBeTruthy(); - expect(typeof capturedServerImpl.Fetch).toBe('function'); - }); - - test('listenForPeers: calling twice shuts previous server down', async () => { - const cm = new ConnectionManager('p.proto', 'c:1'); - await cm.listenForPeers(50100, 'http://localhost:41000/api/'); - const firstServer = (grpc.Server as any).mock.results[0].value; - - await cm.listenForPeers(50101, 'http://localhost:41000/api/'); - expect(firstServer.forceShutdown).toHaveBeenCalled(); - }); - - test('p2p Fetch: registers new incoming receiving connection, forwards to local API, maps response', async () => { - const cm = new ConnectionManager('p.proto', 'c:1'); - await cm.listenForPeers(50102, 'http://local/api/'); - - // Prepare incoming call and callback - const call = { - getPeer: () => 'client-42', - request: { - method: 'post', - path: 'echo', - headers: { 'content-type': 'application/json' }, - body: Buffer.from(JSON.stringify({ ping: true })), - }, - } as any; - const callback = jest.fn(); - - // Mock fetch response - // @ts-ignore - global.fetch.mockResolvedValue({ - status: 202, - headers: { - forEach: (fn: (v: string, k: string) => void) => { - fn('application/json', 'content-type'); - fn('abc', 'x-extra'); - }, - }, - arrayBuffer: async () => Buffer.from(JSON.stringify({ ok: 1 })), - }); - - const before = cm.connections.receiving.length; - await capturedServerImpl.Fetch(call, callback); - - expect(global.fetch).toHaveBeenCalledWith('http://local/api/echo', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ ping: true }), - }); - - // Response mapped back to gRPC - expect(callback).toHaveBeenCalledWith(null, { - status: 202, - headers: { 'content-type': 'application/json', 'x-extra': 'abc' }, - body: expect.any(Buffer), - }); - - // Receiving connection was created & stored - const after = cm.connections.receiving.length; - expect(after).toBeGreaterThan(before); - - const found = cm.getConnectionByAddress('client-42', ConnectionDirection.RECEIVING); - expect(found).toBeDefined(); - expect(infoMessageMock).toHaveBeenCalledWith(expect.stringContaining('Incoming request from client-42')); - expect(infoMessageMock).toHaveBeenCalledWith(expect.stringContaining('New incoming connection registered for: client-42')); - }); - - test('p2p Fetch: uses existing receiving connection when present (no duplicate creation)', async () => { - const cm = new ConnectionManager('p.proto', 'c:1'); - await cm.listenForPeers(50103, 'http://local/api/'); - - cm.createNewConnection('client-77', ConnectionDirection.RECEIVING); - - // @ts-ignore - global.fetch.mockResolvedValue({ - status: 200, - headers: { forEach: (fn: any) => fn('text/plain', 'content-type') }, - arrayBuffer: async () => Buffer.from('ok'), - }); - - const call = { - getPeer: () => 'client-77', - request: { method: 'get', path: 'pong', headers: {}, body: undefined }, - } as any; - - const callback = jest.fn(); - - const before = cm.connections.receiving.length; - await capturedServerImpl.Fetch(call, callback); - - // No new receiving connection added - const after = cm.connections.receiving.length; - expect(after).toBe(before); - - // Forwarded with GET and no body - expect(global.fetch).toHaveBeenCalledWith('http://local/api/pong', { - method: 'GET', - headers: {}, - body: undefined, - }); - - expect(callback).toHaveBeenCalledWith( - null, - expect.objectContaining({ - status: 200, - headers: { 'content-type': 'text/plain' }, - body: expect.any(Buffer), - }) - ); - }); - - test('p2p Fetch: on forward error returns INTERNAL and logs error', async () => { - const cm = new ConnectionManager('p.proto', 'c:1'); - await cm.listenForPeers(50104, 'http://local/api/'); - - // @ts-ignore - global.fetch.mockRejectedValue(new Error('err')); - - const call = { - getPeer: () => 'err-client', - request: { method: 'get', path: 'fail', headers: {} }, - } as any; - const callback = jest.fn(); - - await capturedServerImpl.Fetch(call, callback); - - expect(errorMessageMock).toHaveBeenCalledWith(expect.stringContaining('Error forwarding request')); - expect(callback).toHaveBeenCalledWith( - expect.objectContaining({ - code: grpc.status.INTERNAL, - message: 'err', - }) - ); - }); -}); diff --git a/Tokenization/backend/wrapper/src/test/connection/Connection.test.ts b/Tokenization/backend/wrapper/src/test/connection/Connection.test.ts deleted file mode 100644 index 8937e9d5e..000000000 --- a/Tokenization/backend/wrapper/src/test/connection/Connection.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * @license - * Copyright 2019-2020 CERN and copyright holders of ALICE O2. - * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. - * All rights not expressly granted are reserved. - * - * This software is distributed under the terms of the GNU General Public - * License v3 (GPL Version 3), copied verbatim in the file "COPYING". - * - * In applying this license CERN does not waive the privileges and immunities - * granted to it by virtue of its status as an Intergovernmental Organization - * or submit itself to any jurisdiction. - */ - -import { Connection } from '../../client/connection/Connection'; -import { ConnectionStatus } from '../../models/connection.model'; - -const FAKE_DIRECTION: any = 'SENDING'; - -let lastPeerClient: any; -const PeerCtorMock = jest.fn((_addr: string, _creds: any) => { - lastPeerClient = { - Fetch: jest.fn(), - }; - return lastPeerClient; -}); - -jest.mock( - '@grpc/grpc-js', - () => { - const original = jest.requireActual('@grpc/grpc-js'); - return { - ...original, - credentials: { - createInsecure: jest.fn(() => ({ insecure: true })), - }, - }; - }, - { virtual: true } -); - -import * as grpc from '@grpc/grpc-js'; - -describe('Connection', () => { - beforeEach(() => { - jest.clearAllMocks(); - lastPeerClient = undefined; - }); - - test('constructor should create connection and set base state correctly', () => { - const conn = new Connection('tok', 'peer:50051', FAKE_DIRECTION, PeerCtorMock); - - expect(grpc.credentials.createInsecure).toHaveBeenCalled(); - expect(PeerCtorMock).toHaveBeenCalledWith('peer:50051', { insecure: true }); - - expect(conn.token).toBe('tok'); - expect(conn.targetAddress).toBe('peer:50051'); - expect(conn.status).toBe(ConnectionStatus.CONNECTED); - expect(conn.direction).toBe(FAKE_DIRECTION); - }); - - test('getter/setter for token should work', () => { - const conn = new Connection('old', 'peer:1', FAKE_DIRECTION, PeerCtorMock); - expect(conn.token).toBe('old'); - conn.token = 'new-token'; - expect(conn.token).toBe('new-token'); - }); - - test('handleRevokeToken should clear token and status to UNAUTHORIZED', () => { - const conn = new Connection('secret', 'peer:x', FAKE_DIRECTION, PeerCtorMock); - conn.handleRevokeToken(); - expect(conn.token).toBe(''); - expect(conn.status).toBe(ConnectionStatus.UNAUTHORIZED); - }); - - test('getter/setter for status should work', () => { - const conn = new Connection('t', 'a', FAKE_DIRECTION, PeerCtorMock); - conn.status = ConnectionStatus.UNAUTHORIZED; - expect(conn.status).toBe(ConnectionStatus.UNAUTHORIZED); - conn.status = ConnectionStatus.CONNECTED; - expect(conn.status).toBe(ConnectionStatus.CONNECTED); - }); - - test('getter for targetAddress should work', () => { - const conn = new Connection('t', 'host:1234', FAKE_DIRECTION, PeerCtorMock); - expect(conn.targetAddress).toBe('host:1234'); - }); - - test('fetch should throw if peer client is not attached', async () => { - const conn = new Connection('t', 'addr', FAKE_DIRECTION, PeerCtorMock); - // @ts-ignore - conn['_peerClient'] = undefined; - - await expect(conn.fetch()).rejects.toThrow('Peer client not attached for addr'); - }); - - test('fetch with defaults should work', async () => { - const conn = new Connection('t', 'addr', FAKE_DIRECTION, PeerCtorMock); - - lastPeerClient.Fetch.mockImplementation((req: any, cb: any) => { - try { - expect(req).toEqual({ - method: 'POST', - path: '/', - headers: {}, - body: Buffer.alloc(0), - }); - cb(null, { status: 200, headers: {}, body: Buffer.alloc(0) }); - } catch (e) { - cb(e); - } - }); - - const resp = await conn.fetch(); - expect(resp.status).toBe(200); - }); - - test('fetch builds request correctly and returns response', async () => { - const conn = new Connection('t', 'addr', FAKE_DIRECTION, PeerCtorMock); - const body = Buffer.from('abc'); - - lastPeerClient.Fetch.mockImplementation((req: any, cb: any) => { - try { - expect(req.method).toBe('PUT'); - expect(req.path).toBe('/api/a'); - expect(req.headers).toEqual({ 'x-a': '1' }); - expect(Buffer.isBuffer(req.body)).toBe(true); - expect(req.body.equals(body)).toBe(true); - cb(null, { - status: 201, - headers: { 'content-type': 'text/plain' }, - body: Buffer.from('ok'), - }); - } catch (e) { - cb(e); - } - }); - - const res = await conn.fetch({ method: 'put', path: '/api/a', headers: { 'x-a': '1' }, body }); - expect(res.status).toBe(201); - expect(await res.text()).toBe('ok'); - }); - - test('fetch should convert Uint8Array to Buffer', async () => { - const conn = new Connection('t', 'addr', FAKE_DIRECTION, PeerCtorMock); - const body = new Uint8Array([1, 2, 3]); - - lastPeerClient.Fetch.mockImplementation((req: any, cb: any) => { - try { - expect(Buffer.isBuffer(req.body)).toBe(true); - expect(req.body.equals(Buffer.from([1, 2, 3]))).toBe(true); - cb(null, { status: 200, headers: {}, body: Buffer.alloc(0) }); - } catch (e) { - cb(e); - } - }); - - const res = await conn.fetch({ body }); - expect(res.status).toBe(200); - }); - - test('fetch should convert string to Buffer', async () => { - const conn = new Connection('t', 'addr', FAKE_DIRECTION, PeerCtorMock); - const body = 'żółć & äöü'; // handling special chars - - lastPeerClient.Fetch.mockImplementation((req: any, cb: any) => { - try { - expect(req.body.equals(Buffer.from(body, 'utf8'))).toBe(true); - cb(null, { status: 200, headers: {}, body: Buffer.from('{"ok":true}') }); - } catch (e) { - cb(e); - } - }); - - const res = await conn.fetch({ method: 'post', path: '/p', headers: {}, body }); - expect(await res.json()).toEqual({ ok: true }); - }); - - test('fetch should reject if body is not allowed', async () => { - const conn = new Connection('t', 'addr', FAKE_DIRECTION, PeerCtorMock); - // @ts-ignore - await expect(conn.fetch({ body: { not: 'allowed' } })).rejects.toThrow('Body must be a string/Buffer/Uint8Array'); - }); - - test('fetch should propagate errors from peer', async () => { - const conn = new Connection('t', 'addr', FAKE_DIRECTION, PeerCtorMock); - const err = new Error('err'); - lastPeerClient.Fetch.mockImplementation((_req: any, cb: any) => cb(err)); - - await expect(conn.fetch({ method: 'GET', path: '/x' })).rejects.toThrow('err'); - }); - - test('fetch should map response', async () => { - const conn = new Connection('t', 'addr', FAKE_DIRECTION, PeerCtorMock); - - const payload = { a: 1, b: 'x' }; - lastPeerClient.Fetch.mockImplementation((_req: any, cb: any) => - cb(null, { - headers: { 'x-k': 'v' }, - body: Buffer.from(JSON.stringify(payload)), - }) - ); - - const res = await conn.fetch({ method: 'GET' }); - expect(res.status).toBe(200); - expect(res.headers).toEqual({ 'x-k': 'v' }); - expect(Buffer.isBuffer(res.body)).toBe(true); - expect(await res.text()).toBe(JSON.stringify(payload)); - expect(await res.json()).toEqual(payload); - }); -}); diff --git a/Tokenization/backend/wrapper/src/test/testCerts/ca.crt b/Tokenization/backend/wrapper/src/test/testCerts/ca.crt new file mode 100644 index 000000000..899799b3c --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/testCerts/ca.crt @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgIUA3wFlpIAu9PcCYrsZQwml1VBbBIwDQYJKoZIhvcNAQEL +BQAwMzELMAkGA1UEBhMCUEwxDTALBgNVBAoMBFRlc3QxFTATBgNVBAMMDFRlc3Qg +Um9vdCBDQTAeFw0yNTA5MjUyMDQyMDlaFw0zNTA5MjMyMDQyMDlaMDMxCzAJBgNV +BAYTAlBMMQ0wCwYDVQQKDARUZXN0MRUwEwYDVQQDDAxUZXN0IFJvb3QgQ0EwggIi +MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDwQYsYLOQyRG5nXHLzTuAXIDJl +nV6eoAOsDItqGCiOVy9T6Y0g+4RcSf99i2DRsOVLGg/jQHpAmo765XVObPiEmE+f +rxgLclXqXNlRQdiaK9LRPIUF0TCcyFJGTVMzHTFt0jHEdiP/2++egoAA/pP1al4y +XwJjWkGgLU7GA/Eou2Gfdc7mxZ4ZtmxkEh7OEqSOVgR+qi1fTfXfTyXF+s1PG4pu +tGNCg/zTh1rlMS9kWcoTF4Bk/RU5nKZ1zOAWOgffd72JiWyoGSYy0Yk2e1fhGgG5 +cWMZCDiOCAdNgwTtVKn8IfTgoZJpXhoegMVH44CDkfb7bkp3ETGfMzKncv4v5C2P +11HjWYOrmQ1bgpy+lR9RR+7Oem9So49UsayqqAYmquOCycnGT9wOX+4qsalnZd/O +J4mHtmUGiK0Lkvfh3X5T7wE7yLuiYJtG4XYwREZkBlusxGyX0lRvWJq3lI93EFqt +p7UWtB+1OjabUpCadypzkbvA19DJ4fhzaPh+A3tfn42RnlVAYAazNRiAy90G4/Mh +MPZzKqe0DTp2i2WG5/NTEivKoSVD27vVKp5Tk4LhgAMmZ/F4uFT0TBGva4q+tEUW +jv6mLKpCtQzgqsjNCSKL5pWnymDme7UN/mGe1ttU8xrI9pyGA5tSb9kO0VkBCcVd +R1mOOjZKx0iR3ctG+QIDAQABo2YwZDASBgNVHRMBAf8ECDAGAQH/AgEBMA4GA1Ud +DwEB/wQEAwIBBjAdBgNVHQ4EFgQU41jbLgGadHiLx0921bLDou0kHQEwHwYDVR0j +BBgwFoAU41jbLgGadHiLx0921bLDou0kHQEwDQYJKoZIhvcNAQELBQADggIBAN+W +xYNn4nGtkEfNaVP46GckMQU8Lq6asXIj/L830JHq+R0gJeiURBqBs5snRqBSx6/W +3Xn+IfFGUttnEPlAkxXsg5R/JnQnggfyQX7Hk3SwedKH0uUqX7NLqAv7tfZJPGU9 +HSuvOzThvSX8N3Vr0zLVJDT94WqXX69HoSp6BZnriVss5RWMvM51QsNirj5379CX +iU7BCQdKBQfGSCQW0Qr4GWZYZHuhpXHcsfrrQ66krdqGLgkOxe3xQgvFQSWEf4OU +d/pqlRIOCnku4g2JR/ph+tuLtxmHdidNBjP27mrtrKx4MsaqimxAYOuHTkni8cLF +01IDq95txBs1fShWE5ritJh5b03ZVmDS0uiVH2IGPBmxz08ysJdUAm6uGJWg3D5X +nJBpJbqzYe6wrZDB48s0yZwo6FX5gfoAG6OR0iWfXMsOrpMOxFz/A739JjxcoFDT +P5qct/z92obgFqp0w/RN/8Dotaw00l5P1IenCE42fLuARelrS8jFKrrjUr2+0Occ +CJ/3us1j7Ln5gYWSlWHTjDRwSyaji3Gi4mnduQUsdkIpI7grh4FGULNOLOZZf3Rj +fKlP9kW5m7MB196MYjQrQXTZM1ZUY5yEeCspsb0UaD78Oq5qXSFfGFZ63BmxPMvi +RzP8neThIVB948nZ0GYMc3SIHBFvwQpFZgkuz2+0 +-----END CERTIFICATE----- diff --git a/Tokenization/backend/wrapper/src/test/testCerts/centralSystem/central-system.crt b/Tokenization/backend/wrapper/src/test/testCerts/centralSystem/central-system.crt new file mode 100644 index 000000000..dd67e5dac --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/testCerts/centralSystem/central-system.crt @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIExzCCAq+gAwIBAgIUG2HcUzPbDD8biqumq+ISyohyeYIwDQYJKoZIhvcNAQEL +BQAwMzELMAkGA1UEBhMCUEwxDTALBgNVBAoMBFRlc3QxFTATBgNVBAMMDFRlc3Qg +Um9vdCBDQTAeFw0yNTA5MjUyMDQ5NDRaFw0yNjA5MjUyMDQ5NDRaMEkxCzAJBgNV +BAYTAlBMMRcwFQYDVQQKDA5FeGFtcGxlQ28gVGVzdDEhMB8GA1UEAwwYY2VudHJh +bC5zeXN0ZW0uc3ZjLmxvY2FsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEA6PdQdC5ol4niS1fiXwiJjEhfvPIAhj+LLLaHv1VG71sS9VmnsoEVJMfGqrkO +FkHrHFRFc1UJKi1se1r4NSlPsSPmTOj9KCgp5WSTxrww6X9hniWLCgC1S2fbmrWQ +O07D/qOGpO1GRgqL1KbVdHDZxhLa4MXevRnlgd2VcY1KaXT15BTQcJRJR+I9iIJF +HkUCdrLPjoJvS2G8gRezrRVC+EgrxTfJOQ2rcUunDDhn+f//cTulWjZ/R/Jy9Byy +qFTGPiwwnLkVBQGLhBSRNEYSvzxpgxipVOTLKPZYHNKyITibno3cYiaS+qCI0GG2 +wY7jrh44I6yQ/dCYMyuu7yxT0QIDAQABo4G8MIG5MAkGA1UdEwQCMAAwDgYDVR0P +AQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjA9BgNVHREE +NjA0ghhjZW50cmFsLnN5c3RlbS5zdmMubG9jYWyCB2NlbnRyYWyCCWxvY2FsaG9z +dIcEfwAAATAdBgNVHQ4EFgQUYLT4W+0UJcnlfU4VQMjbSHS5gYkwHwYDVR0jBBgw +FoAU41jbLgGadHiLx0921bLDou0kHQEwDQYJKoZIhvcNAQELBQADggIBALXQVFHu +z1GDyGNUNN5fEonbv4V8MGO8RU6I0O4ccuYqVGBvDUWVSMFtT4Qc60CcXZF2V8p6 +3FP6PAmDK12mMswWuc7lvgAldxOC+PwC4K8fbu9fl+KbP8lsEikVwqiLWXWOGiRh +xlgFhzYTvgEwa3Ta5hqQPsCnY5+/ybF4l7yxZgL0Qp/OWQJgtYd8AeAWpVayhpzw +WojpJ6x56PIZI9vJ00RmMOQib5fl6e4fKKj2ACt8uorG9kL/sWId2BnCJKFjSl8d +4krZr4ocGYK+yK7KgrunqAXy/NPk1hC/oRryaSznC3oh+83P3emjuf//t0FYhSUQ +g8Urku1v9916ulTM8DsF/eSr8z6BMod60fDrpaDnSY+4hcpJuOMfN1pOWcmQVejW +TgX+pwyKpRnvIOlm7NRz+gv31xMEu/McMQQ/oC9qYh7frOsO5pHt1kI7bJ2X69Dj +rz+y7SoW4Ur/pbevfyWu4kBMdo8Dj1zF2GwYFHDzjU0R814fBHEnkN5Jxnk+ahYl +yNwWnjphabPSGRx3nIgCJ600HvgAK1uKgdTCRoiYhkDxcve3m5wEE9UCgLrcp8HL +ushY02iXu9TsPuIA/3bBeLVeI0JxnyxYjP1YuGF2i2fxF/rZxhVXl12xc2oJAytA +6CRc2j0K3JcjgG5jdt3H36LwPnvQ9HPfF200 +-----END CERTIFICATE----- diff --git a/Tokenization/backend/wrapper/src/test/testCerts/centralSystem/central-system.key b/Tokenization/backend/wrapper/src/test/testCerts/centralSystem/central-system.key new file mode 100644 index 000000000..22044072d --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/testCerts/centralSystem/central-system.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDo91B0LmiXieJL +V+JfCImMSF+88gCGP4sstoe/VUbvWxL1WaeygRUkx8aquQ4WQescVEVzVQkqLWx7 +Wvg1KU+xI+ZM6P0oKCnlZJPGvDDpf2GeJYsKALVLZ9uatZA7TsP+o4ak7UZGCovU +ptV0cNnGEtrgxd69GeWB3ZVxjUppdPXkFNBwlElH4j2IgkUeRQJ2ss+Ogm9LYbyB +F7OtFUL4SCvFN8k5DatxS6cMOGf5//9xO6VaNn9H8nL0HLKoVMY+LDCcuRUFAYuE +FJE0RhK/PGmDGKlU5Mso9lgc0rIhOJuejdxiJpL6oIjQYbbBjuOuHjgjrJD90Jgz +K67vLFPRAgMBAAECggEABeW00KwYE7X214drAJLbwIRYgBT0NHHJWSFpwEstV4PL +sBBL8XXZDixMeCflFmUmyXnMpEXDzKCHvXupCtd33/kTrGC9f9W8ccUhBIfhCRgj +ZXh305H/BOClK35rH0U4KusCzov/GmjL718lyiPNL3lstwHrSIguSiJM1SoJdy/l +aCIif6v5l8/DItDSavQxgI97AC0u7lLJadB460XqeJi3vYPzBg6WxEMMdqRhzzOH +1XIsv+IzHabJmt5J3wFsv2lk1v0Irny+CtWtZTM5mTVr4FcgefNx0t8pVSxRXf9F +DjXXbTSrlPVjZVPENrAr6Sl5YyJeK/UABiRl/BLxDQKBgQD3SQ0qZw41JsCFj6W7 +DGyKwFVNvFibzO2Hb6grHwV3iJKHnppFFpanEMhLiZzgdeTGpQFpR4iM6Ne8ewFQ +zu2P91cGjMBP4HqP/RWtGStZ6X/Br2I66sra6BXTsXGNXSr330xzYiwbDrKtr7rv +Q79ySfRRlwWpTeT6RubbqxB3dwKBgQDxLRPlVYaALeJ1rxuds59NeC5bJGHS8+1h +kiXQgKmG1/5saLUyWXWAT5FJqG/xvtiN+vtmf8jL+KhUYpga0kJeuykR7loJRGdr +7h4uMYmzrP7+5P6tNOBqawGCDZzutXMq2TIJAwy1s+tVE4KWWcv0qE8op4on+/f2 +8A+5HWNw9wKBgHGjU3aJ92B7l3uJQMsNcY/txQW9KScn7HwR1sFCNzvwOg4y14gq +Uj8iGjmEWuBXrTOQPm7IHbtLgWCvUjJ1dXx0WLy8z9+lNA2Za22ppF9kS36Rf129 +6kzg3K70207wYr+YEUTw933Tqk7g89HiW0dFLw6TjVl5X2GYVZzbJu0PAoGBAL6b +BLVkIXeeS/L8YJQDSOx+BgzsNQ/2zm4lhhNCDDlQ7XgaTNItF4s/1zBimY5yaU3U +xOmeJkDmFYsTnOjdsaySuIO+X5QhZqdLOrkBV7YUDDfBHXIgbxhL15ZEUfnql8mO +fFfY/CuCtYO4dqWC9Ik4l88mki7FmZSk55hCnLvDAoGBAKEjQS4GVd+9Ku/HVi71 +OG2vfKEyGfTyyOB/3c99BMWNOkQfMNrxuHR8XhTPi2LvFE9nndBlMuCLcRS417iL +Gvd7FazAaO1lRO1tlqOym1z2gx/j2k+2BrIV+vOzg3PEJeXZAihLHTSsfhNIc4xW +ZeR3z5nE4Pz9AOr/YFKi9Xqm +-----END PRIVATE KEY----- diff --git a/Tokenization/backend/wrapper/src/test/testCerts/clientListener/client-b-client.crt b/Tokenization/backend/wrapper/src/test/testCerts/clientListener/client-b-client.crt new file mode 100644 index 000000000..08482178c --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/testCerts/clientListener/client-b-client.crt @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIEuDCCAqCgAwIBAgIURLvrnhcyzZ0UTbuI1tHGoXXFWc0wDQYJKoZIhvcNAQEL +BQAwMzELMAkGA1UEBhMCUEwxDTALBgNVBAoMBFRlc3QxFTATBgNVBAMMDFRlc3Qg +Um9vdCBDQTAeFw0yNTA5MjUyMDU2MTdaFw0yNjA5MjUyMDU2MTdaMEYxCzAJBgNV +BAYTAlBMMRcwFQYDVQQKDA5FeGFtcGxlQ28gVGVzdDEeMBwGA1UEAwwVY2xpZW50 +LWItY2xpZW50LmxvY2FsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +xxqgLBJFeQgLcRvkPj6S16guSJsJN/MJxGAnmWQF3utr9neUXeID2KUEMNUT2b73 +EO/LD3z+8JvDhfG4J9Y1/ny/CuaAbmHKXH/ot0nIwWjd11yXjhWyXQuNqGHiuniX +2KaDH5+rIPXYU6eUjx+V3VX0iiYEFUizPRUhzmJ8AONSu43OuMdhZo7Frb4qUc4/ +ioVqhiAb3Sdm/nKDWI9OQR6Ux8Mc2OaPY3wQA4r3ZBz9oJU3G7BL85bclmk1PDZh +R6T0/oo09FiTj8zTZ8vooarJH+TAD2EBsTwfIdBT1yuECAueztxaos2q2TUsJz3f +6hQaEnOqiz6nZ8FceXudgQIDAQABo4GwMIGtMAkGA1UdEwQCMAAwDgYDVR0PAQH/ +BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMCMDsGA1UdEQQ0MDKCFWNsaWVudC1i +LWNsaWVudC5sb2NhbIIIY2xpZW50LWKCCWxvY2FsaG9zdIcEfwAABTAdBgNVHQ4E +FgQUTzAFrkegQMRXfjt4djXC5N4YHnYwHwYDVR0jBBgwFoAU41jbLgGadHiLx092 +1bLDou0kHQEwDQYJKoZIhvcNAQELBQADggIBAB+vXi5ThGYQt7RUx92Vjy2++Yiz +WztajTms/ac2B2JZbW9dl1PYv+t5hrkiXp8Q21eWq7ZPbd+7QbzvQsOxSGEKNBQp +0A4CeI67YkbYjJPzxjpDxiYo8+wmOtGgWRok9P4FLN5dGVo9/3JQHrn+3m20/6DT +FqTqm+mC3FkSjAJzPnd1wOmPxS2xENE/L5x4KHR91xkCeaHXjQky9gQs7vzY4uto +kiuB/elxFq2l4XN4BI6A65261AyelB15qpTKBGICGNO56hIcrGIjoFyYQnKyJrk4 +yylH0LtACnEV0lzS9l7FtRNERQ5xDBRvcqK7X7XCwkKqXfYN04STiVEEqhSoYDF2 +INJeV2FN7CJQueCuiKqG1S+zd7uXJ9a7dx5Qk3boJVBfX2WjK5BbiSpjoq+x7tCG +zwtHpBDaB8K6Ee0XICFcDZlPB214XaGjrx6iavs5ppP6261f20QMI3xtTVPapMjT +bmC2AoARTdaIxoQaSyCE+QImVxkhHYBePpIHAnAZhBPzF46u+APRAOpklTJ6J+uv +VWCwm71ebXUTSZ7alsScqH+zDmYMYpGuar3qcPHnOJDo0XDoSMYl3kLDrK0UoHci +mtjRzuYBlKyEX8LcqY9teVuarnvxaNtYGbF98L3nFSLGnUfzaCMIbA4xtJcZ7DXu +J9l3OBcmHYWG52f2 +-----END CERTIFICATE----- diff --git a/Tokenization/backend/wrapper/src/test/testCerts/clientListener/client-b-server.crt b/Tokenization/backend/wrapper/src/test/testCerts/clientListener/client-b-server.crt new file mode 100644 index 000000000..949b07297 --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/testCerts/clientListener/client-b-server.crt @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEqjCCApKgAwIBAgIUSKJ0QtJi4yK32QhGXdgKbfADC3owDQYJKoZIhvcNAQEL +BQAwMzELMAkGA1UEBhMCUEwxDTALBgNVBAoMBFRlc3QxFTATBgNVBAMMDFRlc3Qg +Um9vdCBDQTAeFw0yNTA5MjUyMDU2MDhaFw0yNjA5MjUyMDU2MDhaMD8xCzAJBgNV +BAYTAlBMMRcwFQYDVQQKDA5FeGFtcGxlQ28gVGVzdDEXMBUGA1UEAwwOY2xpZW50 +LWIubG9jYWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHGqAsEkV5 +CAtxG+Q+PpLXqC5Imwk38wnEYCeZZAXe62v2d5Rd4gPYpQQw1RPZvvcQ78sPfP7w +m8OF8bgn1jX+fL8K5oBuYcpcf+i3ScjBaN3XXJeOFbJdC42oYeK6eJfYpoMfn6sg +9dhTp5SPH5XdVfSKJgQVSLM9FSHOYnwA41K7jc64x2FmjsWtvipRzj+KhWqGIBvd +J2b+coNYj05BHpTHwxzY5o9jfBADivdkHP2glTcbsEvzltyWaTU8NmFHpPT+ijT0 +WJOPzNNny+ihqskf5MAPYQGxPB8h0FPXK4QIC57O3FqizarZNSwnPd/qFBoSc6qL +PqdnwVx5e52BAgMBAAGjgakwgaYwCQYDVR0TBAIwADAOBgNVHQ8BAf8EBAMCBaAw +EwYDVR0lBAwwCgYIKwYBBQUHAwEwNAYDVR0RBC0wK4IOY2xpZW50LWIubG9jYWyC +CGNsaWVudC1igglsb2NhbGhvc3SHBH8AAAQwHQYDVR0OBBYEFE8wBa5HoEDEV347 +eHY1wuTeGB52MB8GA1UdIwQYMBaAFONY2y4BmnR4i8dPdtWyw6LtJB0BMA0GCSqG +SIb3DQEBCwUAA4ICAQAfNbYmzhNKTJT+e4VZaJdqxFmsm2oHUtRXHVKHcPKYZEd5 +ujKtIjbdhjQ82Rhfmof9cydvAK8qEm+ydwUBvN/9q7Dd4V3rafKbsrVizB63HbSl +AZujvRxwIKF9Gzc3Sqliy1/LZYfk+FHHooUtzmL/K5cTVlHaBqT8m4zmqp4djFjQ +YnshmdaMBmgmgluO4/JyPswFHpRlKcp29GA8n39/+25yFyiIunryypCwPAFb/owh +sXVshhs04+JUwEdWGHoesbhjbIik706poPOlvUf9xHDcB6PXIwPmo08+1u2QEaV3 +Dqw4TjNcUA7OJdxzKhF0J4tVXAD1Hg2yrOYedtTeXDPntjgb3Uq4DWnAAJ+fMF1U +T1vJogzgzq6y5jl0KClqpSA9dOKt4IG2hL5WcoudyTk0ao4wkVdOQwR1vNz25Fub +LSl582PpHxvK3GYk4PegoPnHzz02IN4B+AxUDvXPH4HIZ4QtubnX4x4j8Kk4bPCK +ZBBA3t8K/6W/bjOB0Xh+LyXf9dIndVmFHC0iOf8YQgyNHhXhzpbpM/LWdDk3aEiG +6y99wNdWuu7F3T7wFk7dDhxxroHqxUjsP8921LD0JDqdryWQK6wE23fSpiThOEDN +o2FnBG3514QI/v6zlUWY4LtqJO2UCwPLbWAsuXd+AxWOO3urg0ucW0r1UNzmIQ== +-----END CERTIFICATE----- diff --git a/Tokenization/backend/wrapper/src/test/testCerts/clientListener/client-b.key b/Tokenization/backend/wrapper/src/test/testCerts/clientListener/client-b.key new file mode 100644 index 000000000..b2fc830da --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/testCerts/clientListener/client-b.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDHGqAsEkV5CAtx +G+Q+PpLXqC5Imwk38wnEYCeZZAXe62v2d5Rd4gPYpQQw1RPZvvcQ78sPfP7wm8OF +8bgn1jX+fL8K5oBuYcpcf+i3ScjBaN3XXJeOFbJdC42oYeK6eJfYpoMfn6sg9dhT +p5SPH5XdVfSKJgQVSLM9FSHOYnwA41K7jc64x2FmjsWtvipRzj+KhWqGIBvdJ2b+ +coNYj05BHpTHwxzY5o9jfBADivdkHP2glTcbsEvzltyWaTU8NmFHpPT+ijT0WJOP +zNNny+ihqskf5MAPYQGxPB8h0FPXK4QIC57O3FqizarZNSwnPd/qFBoSc6qLPqdn +wVx5e52BAgMBAAECggEAA0i8JZ3ziWiJj8cO/7vWfjom8UmlYEfg/F09qfkNY7zs +Xfdg+h91QsiOBiQtnKTavGvIJKxCJEPdeMMg739ICreSCyL8MVXpmZb+hq9v4UjS +h+/eDBjthT1gi8t5iuvcTVWJyia/Et8bP13/RFEYDruROgogfR1i33oOwbG8K+OM +iDSIRo9swBaVNqFuWZKqZr1pjY9KUfh4jnSA+x1hueVHREJYt6qvX2KNhHFhyfyo +RRuLSKmz7Xx3sTu7qQD1NRKsD/8lM4oZWGPTit0F6qx+FfzbI0nBn9In8czmrPcm +VqwdgT5aR5I6lfzAj6kMHlmY2n0f4J0Hk6Ha6MgkJQKBgQD6AnjeToUw6PcIbTrJ +18mDfzOCMhQjQHDXipHrJ06K65B+4XX8/oTkRahyeqn4sbKKHP11e6bsDPVNRK0P +9G3wYw7nPNcGtF1pojWvh0HxBTJ/iugDgZT5ngtwbydvAT7+m6De3DicEJo5ZUPP +IAweL+qf9Nh30PtKiOSgJ2GrRwKBgQDL3+eX0sQg+dfYb3zAJmsLwXoY4fAOGc1o +KI4UI26Bq4TPrJv9guaDjSYNE62M7+H7vWudlG+KPxt6jNaxxpIo5J1c7qVl+gx6 +kTDZ053peVLtWJzLrRT9/e238bCYnKfFpKwRzUGT9kEkMvmFqb56lnhKBXS/OPuP +3dAOWHvE9wKBgAJM4YXSHSGdEyDNuHvA84a1NekdwtesMR2alcsfGnbmwfaY5ngE +c36SMYGUJVo3cFga+i4JjDihyeQDHMCH1DchAjMYeTYDlNRy/KF30iCAlr1brtTR +bWh6jspjC27XCRhYoDtMtWyiLnkWuHAAcHwansMIArHfh2BhMBFVK23jAoGAGwrf +GFdfppQdWlsnbAFsj4mhXW2SvvwTL+65MdilTtPmcPmPU2gqlWaClpd2nMww6Ihu +nt9SkD7gsTe/PqN9PaldajdJfyZUw2lA1pPoTVDHfC4V1jpmH26wOob3ira01lWK +cW4NdcfjSh7s1Br45h/RYtgobTjsvV+Jum1oNW8CgYAeY82AEydYzqjZRH66xAjQ +pSxaT6B2YS5JqJFbRAtHA/ndApHbW7ALX5vqoJ6EkJ+aORQQMZUkX32n3Y0WFNE2 +FR9JEcnBjimwmG+JTBRCk1luamsZZpH/sHZohH1bsq9+dGo9xIeeBdi5DPxpdsKM +6NuWljVhDbuJeRiamlo5tg== +-----END PRIVATE KEY----- diff --git a/Tokenization/backend/wrapper/src/test/testCerts/clientListener/client-b.pub.pem b/Tokenization/backend/wrapper/src/test/testCerts/clientListener/client-b.pub.pem new file mode 100644 index 000000000..cf74b90e7 --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/testCerts/clientListener/client-b.pub.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxxqgLBJFeQgLcRvkPj6S +16guSJsJN/MJxGAnmWQF3utr9neUXeID2KUEMNUT2b73EO/LD3z+8JvDhfG4J9Y1 +/ny/CuaAbmHKXH/ot0nIwWjd11yXjhWyXQuNqGHiuniX2KaDH5+rIPXYU6eUjx+V +3VX0iiYEFUizPRUhzmJ8AONSu43OuMdhZo7Frb4qUc4/ioVqhiAb3Sdm/nKDWI9O +QR6Ux8Mc2OaPY3wQA4r3ZBz9oJU3G7BL85bclmk1PDZhR6T0/oo09FiTj8zTZ8vo +oarJH+TAD2EBsTwfIdBT1yuECAueztxaos2q2TUsJz3f6hQaEnOqiz6nZ8FceXud +gQIDAQAB +-----END PUBLIC KEY----- diff --git a/Tokenization/backend/wrapper/src/test/testCerts/clientSender/client-a-client.crt b/Tokenization/backend/wrapper/src/test/testCerts/clientSender/client-a-client.crt new file mode 100644 index 000000000..72ddb5561 --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/testCerts/clientSender/client-a-client.crt @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIEuDCCAqCgAwIBAgIUTk9RrYkrtmjMiKjWusjyURgmSUEwDQYJKoZIhvcNAQEL +BQAwMzELMAkGA1UEBhMCUEwxDTALBgNVBAoMBFRlc3QxFTATBgNVBAMMDFRlc3Qg +Um9vdCBDQTAeFw0yNTA5MjUyMDU1MTVaFw0yNjA5MjUyMDU1MTVaMEYxCzAJBgNV +BAYTAlBMMRcwFQYDVQQKDA5FeGFtcGxlQ28gVGVzdDEeMBwGA1UEAwwVY2xpZW50 +LWEtY2xpZW50LmxvY2FsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +nJySLgwWLpLbt8EqFDfhKciKJGlxRUfcn/2u2EyxnkpKwwiLDSzKguZZ50xKviDt +Jao2FuZ251uyJDTfuPYt549OklkKIQYFSmP4MxNDAz501TVSJ45a9WQugScGc9lk +invmIADGBEa0rj2keRkT4MYvnWT2IGQJ91N99g9tDoQVPem5naHU1PxIwyxVVRIv +6mVCaro6OULqx6iFvDffvL0ef/5lbt8+vqX4QWwPH8rF5CvaV1KYYMkvQEXSn615 +8FE2YUepEN6wGEEDAIr98D5vNlgabkpLzxDULFT+tdk7v6Shb7bKql1W0QlBkEh/ +mFDCEkqOesGXLuzPG8pVAwIDAQABo4GwMIGtMAkGA1UdEwQCMAAwDgYDVR0PAQH/ +BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMCMDsGA1UdEQQ0MDKCFWNsaWVudC1h +LWNsaWVudC5sb2NhbIIIY2xpZW50LWGCCWxvY2FsaG9zdIcEfwAAAzAdBgNVHQ4E +FgQUScQWL/kot0/d3H9JQ9sZsO3xniAwHwYDVR0jBBgwFoAU41jbLgGadHiLx092 +1bLDou0kHQEwDQYJKoZIhvcNAQELBQADggIBALVJfl1YWb3mrXT51+pZ4pYJfY0Z +iXCGAMVLUtdnhgMmX5GVJhWvCAt+vwHggOJ+EA+1vw5CRe/GLDa5QurOtLtH6uBt +OjenYmOKRZ5f+mG8ZR20PyhH70m8DZ01OGlfuieFb8KgyYtEFNLCFZWxVDlvdwNL +2HVPSjM0JYudBELnTs4N8YZUTLGDRDZ9sz/KQYMJrSojN45k05qqr/EXWHwVDzVL +LefvuKi6H6DLzmU+oDy9TRcCydV4/h6i3MUxWm/IBrgoKIg4fmd/Evnen9KSc6md +yMHoKR6iHcha526txtUu4w0/0Le45yYxE1/eNm5jfMNwTFuoTbmlk+lItXd5Upn7 +1Pk+TF81WLl8prdvVoZPVYaKbj80JQlleQlWu76SaaY9ofkbRwP85npJmdl0wDCu +Bmil+ziBxzK73TT+UMBbRKRmXdeEh3fjQ8X4qlRNVPEarj1f2UiZ+45G/eFogA2r +EtVTqQ5MetNtWssgK0GFf2KeUIfXRdvYuFvLOhcd7uccxThq+o9KDFIulPzhy6uq +nu2AS8NELydQeHh6GjKqsxNoMS5l+YSzTGPvFWTYqzfRH5+h2J0H8Oex2Grb5C9A +35F8f35zLViv8C9mU32W9bSgcJElKaOumgBLbRtfrzHesFBFyOkTbtWnKJJJwXQH +7QZyDraKRsXdHAMr +-----END CERTIFICATE----- diff --git a/Tokenization/backend/wrapper/src/test/testCerts/clientSender/client-a-server.crt b/Tokenization/backend/wrapper/src/test/testCerts/clientSender/client-a-server.crt new file mode 100644 index 000000000..23fc77a5d --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/testCerts/clientSender/client-a-server.crt @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEqjCCApKgAwIBAgIULDdlrJiTv9xZIx3QqggR9bBIgwswDQYJKoZIhvcNAQEL +BQAwMzELMAkGA1UEBhMCUEwxDTALBgNVBAoMBFRlc3QxFTATBgNVBAMMDFRlc3Qg +Um9vdCBDQTAeFw0yNTA5MjUyMDU1MDJaFw0yNjA5MjUyMDU1MDJaMD8xCzAJBgNV +BAYTAlBMMRcwFQYDVQQKDA5FeGFtcGxlQ28gVGVzdDEXMBUGA1UEAwwOY2xpZW50 +LWEubG9jYWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcnJIuDBYu +ktu3wSoUN+EpyIokaXFFR9yf/a7YTLGeSkrDCIsNLMqC5lnnTEq+IO0lqjYW5nbn +W7IkNN+49i3nj06SWQohBgVKY/gzE0MDPnTVNVInjlr1ZC6BJwZz2WSKe+YgAMYE +RrSuPaR5GRPgxi+dZPYgZAn3U332D20OhBU96bmdodTU/EjDLFVVEi/qZUJqujo5 +QurHqIW8N9+8vR5//mVu3z6+pfhBbA8fysXkK9pXUphgyS9ARdKfrXnwUTZhR6kQ +3rAYQQMAiv3wPm82WBpuSkvPENQsVP612Tu/pKFvtsqqXVbRCUGQSH+YUMISSo56 +wZcu7M8bylUDAgMBAAGjgakwgaYwCQYDVR0TBAIwADAOBgNVHQ8BAf8EBAMCBaAw +EwYDVR0lBAwwCgYIKwYBBQUHAwEwNAYDVR0RBC0wK4IOY2xpZW50LWEubG9jYWyC +CGNsaWVudC1hgglsb2NhbGhvc3SHBH8AAAIwHQYDVR0OBBYEFEnEFi/5KLdP3dx/ +SUPbGbDt8Z4gMB8GA1UdIwQYMBaAFONY2y4BmnR4i8dPdtWyw6LtJB0BMA0GCSqG +SIb3DQEBCwUAA4ICAQAKtTGDurzAkajMDN+WqhiA6daqIstsRzLz9VnBwqIlWcOr +c1As4ah+YZSf2Qw1AMZ387fpk4oF2QZD4ZG7kigZdn5ricFVhBRMZUzJV1ommu2H +8Mub+oRyKQ/TtRqkq1JJqLKz7rDxBMM9LxSBPR4Nj2C4IVioxI5KYXYxlmqMeoYA +sMglGi8c3loRSy9LNwvcQu+UPI6kcFG+J0rfXJlWx10GRWIURudXt8oAAIVBLvSt +HR29TXWjOTULwqun0y5V4eksJek5jEhGTWuODAdPmCSSjAE4VSLECex/jql6jNFB +zmE9Q7vcss4zR9TASMeJYT3S+mXVb9sNf4ps+9rhx63tluSCH1vwtpMoQXucbIgo +tBUz+5gCIA7n1bMUJ8b1MajnTVH0nJa1ZWi0zTYnSd6WL0S0Se5exZ5Ws1ZWnFl9 +lVPCn2Mt8agRu0s0VAT7t4nY4VjHTDqjj9Z99tcfUWCO8gAAR28kkqdRYxrgVMkx +pv8IwTt0tBldDnpwdCqBnXP75sta4Gq7IOpe0oQB6kizWqbII84tYSxUch9SkkaH +rE7BhGtUywAJxc+dnAFuePuu6BE2ZsQK86FpuHYIxR6DU7hH1i8258qxGt0/EVBg +ekhyT6tFaAWl5N+OVmEu1JvNdqNiw6sJc+xy9AcviWlAOvkGd2Aw0eTRNQLboQ== +-----END CERTIFICATE----- diff --git a/Tokenization/backend/wrapper/src/test/testCerts/clientSender/client-a.key b/Tokenization/backend/wrapper/src/test/testCerts/clientSender/client-a.key new file mode 100644 index 000000000..7377803ae --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/testCerts/clientSender/client-a.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCcnJIuDBYuktu3 +wSoUN+EpyIokaXFFR9yf/a7YTLGeSkrDCIsNLMqC5lnnTEq+IO0lqjYW5nbnW7Ik +NN+49i3nj06SWQohBgVKY/gzE0MDPnTVNVInjlr1ZC6BJwZz2WSKe+YgAMYERrSu +PaR5GRPgxi+dZPYgZAn3U332D20OhBU96bmdodTU/EjDLFVVEi/qZUJqujo5QurH +qIW8N9+8vR5//mVu3z6+pfhBbA8fysXkK9pXUphgyS9ARdKfrXnwUTZhR6kQ3rAY +QQMAiv3wPm82WBpuSkvPENQsVP612Tu/pKFvtsqqXVbRCUGQSH+YUMISSo56wZcu +7M8bylUDAgMBAAECggEANyBDsjKt8inebjFvmttKhfchbQyygsT3S1ez3k4srT+Q +TlNpArOz+tyTY7+uhXs4jmv6CxiHXQuhSm5UG5qH8Py4FvqBfrtMTHGg8XWDvpYS +8OOKbgMFUGA5oFt4wXmRks9m4vfyu5mZysVG6htiLFoGc5wQqLkd6vF4Io8uf4+A +Dd/oY85K7x/JnrNQaF7LmpeqTMRlhtzSzGNVqEwT9b9FoQRADGHrg1ksY5xXixAw +Jf+wvJqd6AaoxzK0rDuZiQpVnjepJ6aeip61RL7coc+yAlkXhfDOWvzWCefRFUTf +Q/iTyOset8ejRg2mF8InLUKYC2kwKLeucVvFkijz2QKBgQDbgf5L2aK/lUEQVtQf +ueqCc89PJZHU34tEXRC0doGYLQnhZQPVvLeTv+CkZ7GibUxPiMLdtzzfyFxlLbs+ +gUxBXV540hI3acpDqdiiZmHeYtMPvyjUkkP1ymQSnLwwjOkBh7Wt/+zMBEHtO8hz +Mo5vfoV6JV+b+JQg2f7cyd1t2wKBgQC2pclNJ1Dj58Vgmfd2EZhZgG0XRVPfR7PT +QjEIcnFmmNvEiN83dYqYw8fVOegcXrCFMTP3aW6ONoGnk+owhTSBtEfYNCKOwIi9 +GHM+MJFNIgxxPM+xSvpxHtAF9meYFkMRqjMJPM8ICz04Uz3AsfbdveYwUNMudSap +znigNKfh+QKBgDCvvH+GXhqwOCYvnA0NZ35XwXuEkbvteS5IlhPw1P2zv6VGins1 +yGH1BRZyCWxFYc+iPdZ/dfkMr7GhWw6aDxfQZcvWjEPOKxam7W3X141DzhyIAb5k +Ur6JjXizWupJ1sSIHTvir9rwds7vm54xcHY6UdCtyW8Gy5QdxfGitIJRAoGAbJ2I +aT5RJ0bEJJ9K/saV39u0hBsxNl2QfbgmKozMDSQnxOdUPsnCgvgiVRXbh0t0E7Df +42iqWx3k2n/my7XbNKq98r+GMXgjmLf6iGgfcEwoNAriw97/sdeOA421q0bJ2a5q +LTshLvpoDJ/L4FS0psbwJZlbDIyUUnS7XSITGBkCgYBNvMefT86P85yN5nDyxRjO +lCitw08NjE+6WZWL6BTbRqVovFG2HAaWGEjG2+bpApy4S4AN3NmmOby38ZZDR7bJ +bQxCHRt61yqX6IphkRrzv8K7DbrN3jnKO2FN8TBWwcvFzx2d5hl8Nsv2OmEyixZZ +ySbU4WOBCdu/mJy/+Xb9gA== +-----END PRIVATE KEY----- diff --git a/Tokenization/backend/wrapper/src/test/testCerts/clientSender/client-a.pub.pem b/Tokenization/backend/wrapper/src/test/testCerts/clientSender/client-a.pub.pem new file mode 100644 index 000000000..f565de4a2 --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/testCerts/clientSender/client-a.pub.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnJySLgwWLpLbt8EqFDfh +KciKJGlxRUfcn/2u2EyxnkpKwwiLDSzKguZZ50xKviDtJao2FuZ251uyJDTfuPYt +549OklkKIQYFSmP4MxNDAz501TVSJ45a9WQugScGc9lkinvmIADGBEa0rj2keRkT +4MYvnWT2IGQJ91N99g9tDoQVPem5naHU1PxIwyxVVRIv6mVCaro6OULqx6iFvDff +vL0ef/5lbt8+vqX4QWwPH8rF5CvaV1KYYMkvQEXSn6158FE2YUepEN6wGEEDAIr9 +8D5vNlgabkpLzxDULFT+tdk7v6Shb7bKql1W0QlBkEh/mFDCEkqOesGXLuzPG8pV +AwIDAQAB +-----END PUBLIC KEY----- diff --git a/Tokenization/backend/wrapper/src/test/testCerts/testCerts.ts b/Tokenization/backend/wrapper/src/test/testCerts/testCerts.ts new file mode 100644 index 000000000..92c6a308c --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/testCerts/testCerts.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { CentralSystemConfig, gRPCWrapperConfig } from "models/config.model"; +import path from "path"; +import * as fs from "fs"; + +export const getTestCentralCertPaths = + (): CentralSystemConfig["serverCerts"] => { + const CA_CERT_PATH = path.join(__dirname, "./ca.crt"); + const SERVER_CERT_PATH = path.join( + __dirname, + "./centralSystem/central-system.crt" + ); + const SERVER_KEY_PATH = path.join( + __dirname, + "./centralSystem/central-system.key" + ); + + return { + caCertPath: CA_CERT_PATH, + certPath: SERVER_CERT_PATH, + keyPath: SERVER_KEY_PATH, + }; + }; + +export const getTestClientListenerCertPaths = + (): gRPCWrapperConfig["clientCerts"] => { + const CA_CERT_PATH = path.join(__dirname, "./ca.crt"); + const CLIENT_CERT_PATH = path.join( + __dirname, + "./clientListener/client-b-client.crt" + ); + const CLIENT_PRIVATE_KEY_PATH = path.join( + __dirname, + "./clientListener/client-b.key" + ); + const CLIENT_PUBLIC_KEY_PATH = path.join( + __dirname, + "./clientListener/client-b.pub.pem" + ); + + return { + caCertPath: CA_CERT_PATH, + certPath: CLIENT_CERT_PATH, + privateKeyPath: CLIENT_PRIVATE_KEY_PATH, + publicKeyPath: CLIENT_PUBLIC_KEY_PATH, + }; + }; + +export const getTestClientListenerServerCertPaths = + (): gRPCWrapperConfig["clientCerts"] => { + const CA_CERT_PATH = path.join(__dirname, "./ca.crt"); + const CLIENT_CERT_PATH = path.join( + __dirname, + "./clientListenerServer/client-b-server.crt" + ); + const CLIENT_PRIVATE_KEY_PATH = path.join( + __dirname, + "./clientListener/client-b.key" + ); + const CLIENT_PUBLIC_KEY_PATH = path.join( + __dirname, + "./clientListener/client-b.pub.pem" + ); + + return { + caCertPath: CA_CERT_PATH, + certPath: CLIENT_CERT_PATH, + publicKeyPath: CLIENT_PUBLIC_KEY_PATH, + privateKeyPath: CLIENT_PRIVATE_KEY_PATH, + }; + }; + +export const getTestClientSenderCertPaths = + (): gRPCWrapperConfig["clientCerts"] => { + const CA_CERT_PATH = path.join(__dirname, "./ca.crt"); + const CLIENT_CERT_PATH = path.join( + __dirname, + "./clientSender/client-a-client.crt" + ); + const CLIENT_PRIVATE_KEY_PATH = path.join( + __dirname, + "./clientSender/client-a.key" + ); + const CLIENT_PUBLIC_KEY_PATH = path.join( + __dirname, + "./clientSender/client-a.pub.pem" + ); + + return { + caCertPath: CA_CERT_PATH, + certPath: CLIENT_CERT_PATH, + privateKeyPath: CLIENT_PRIVATE_KEY_PATH, + publicKeyPath: CLIENT_PUBLIC_KEY_PATH, + }; + }; + +export const getTestCerts = () => { + const CA_CERT_PATH = path.join(__dirname, "./ca.crt"); + const SERVER_CERT_PATH = path.join( + __dirname, + "./centralSystem/central-system.crt" + ); + const SERVER_KEY_PATH = path.join( + __dirname, + "./centralSystem/central-system.key" + ); + + const caCert = fs.readFileSync(CA_CERT_PATH); + const clientCert = fs.readFileSync(SERVER_CERT_PATH); + const clientKey = fs.readFileSync(SERVER_KEY_PATH); + + return { caCert, clientCert, clientKey }; +}; diff --git a/Tokenization/backend/wrapper/src/test/utils/connection/reconnectionScheduler.test.ts b/Tokenization/backend/wrapper/src/test/utils/connection/reconnectionScheduler.test.ts index 8107267cd..ed9e7ab1e 100644 --- a/Tokenization/backend/wrapper/src/test/utils/connection/reconnectionScheduler.test.ts +++ b/Tokenization/backend/wrapper/src/test/utils/connection/reconnectionScheduler.test.ts @@ -45,7 +45,7 @@ describe('ReconnectionScheduler', () => { test("schedule's first attempt should schedule and call reconnectCallback", () => { scheduler.schedule(); - expect(logger.infoMessage).toHaveBeenCalledWith('Recconection attempt #1: Sleep for 2000 ms.'); + expect(logger.infoMessage).toHaveBeenCalledWith('Reconnection attempt #1: Sleep for 2000 ms.'); expect(reconnectCallback).not.toHaveBeenCalled(); jest.advanceTimersByTime(1999); @@ -59,7 +59,7 @@ describe('ReconnectionScheduler', () => { jest.advanceTimersByTime(2000); scheduler.schedule(); - expect(logger.infoMessage).toHaveBeenLastCalledWith('Recconection attempt #2: Sleep for 4000 ms.'); + expect(logger.infoMessage).toHaveBeenLastCalledWith('Reconnection attempt #2: Sleep for 4000 ms.'); jest.advanceTimersByTime(4000); expect(reconnectCallback).toHaveBeenCalledTimes(2); }); @@ -75,15 +75,15 @@ describe('ReconnectionScheduler', () => { ); scheduler.schedule(); - expect(logger.infoMessage).toHaveBeenLastCalledWith('Recconection attempt #1: Sleep for 2000 ms.'); + expect(logger.infoMessage).toHaveBeenLastCalledWith('Reconnection attempt #1: Sleep for 2000 ms.'); jest.advanceTimersByTime(2000); scheduler.schedule(); - expect(logger.infoMessage).toHaveBeenLastCalledWith('Recconection attempt #2: Sleep for 3000 ms.'); + expect(logger.infoMessage).toHaveBeenLastCalledWith('Reconnection attempt #2: Sleep for 3000 ms.'); jest.advanceTimersByTime(3000); scheduler.schedule(); - expect(logger.infoMessage).toHaveBeenLastCalledWith('Recconection attempt #3: Sleep for 3000 ms.'); + expect(logger.infoMessage).toHaveBeenLastCalledWith('Reconnection attempt #3: Sleep for 3000 ms.'); }); test('schedule() should not schedule again if it is scheduled', () => { @@ -108,7 +108,7 @@ describe('ReconnectionScheduler', () => { expect(reconnectCallback).not.toHaveBeenCalled(); scheduler.schedule(); - expect(logger.infoMessage).toHaveBeenLastCalledWith('Recconection attempt #1: Sleep for 2000 ms.'); + expect(logger.infoMessage).toHaveBeenLastCalledWith('Reconnection attempt #1: Sleep for 2000 ms.'); }); test('reset() should ignore another reset due to isResseting variable', () => { diff --git a/Tokenization/backend/wrapper/src/test/utils/queues/RetryQueue.test.ts b/Tokenization/backend/wrapper/src/test/utils/queues/RetryQueue.test.ts new file mode 100644 index 000000000..9bcddb21a --- /dev/null +++ b/Tokenization/backend/wrapper/src/test/utils/queues/RetryQueue.test.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { RetryQueue, RetryTask } from "../../../utils/queues/RetryQueue"; + +describe("RetryQueue", () => { + let queue: RetryQueue; + + beforeEach(() => { + jest.useFakeTimers(); + queue = new RetryQueue(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("should enqueue a task and increase size", () => { + const task = createTask(); + queue.enqueue(task); + expect(queue.size()).toBe(1); + }); + + it("should execute a task after delay and resolve", async () => { + const exec = jest.fn().mockResolvedValue("done"); + const resolve = jest.fn(); + const reject = jest.fn(); + const task = createTask({ exec, resolve, reject }); + + queue.enqueue(task); + jest.runOnlyPendingTimers(); + + // Wait for promise resolution + await Promise.resolve(); + expect(exec).toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledWith("done"); + expect(reject).not.toHaveBeenCalled(); + }); + + it("should retry failed task up to maxRetries and then reject", async () => { + jest.useFakeTimers(); + + const exec = jest.fn().mockRejectedValue(new Error("fail")); + const resolve = jest.fn(); + const reject = jest.fn(); + + const task = createTask({ exec, resolve, reject, tryNo: 0 }); + + const queue = new RetryQueue({ + maxRetries: 2, + baseDelayMs: 1, + maxDelayMs: 10, + jitter: false, + }); + + queue.enqueue(task); + + await jest.runAllTimersAsync(); + + expect(exec).toHaveBeenCalledTimes(3); + expect(reject).toHaveBeenCalledTimes(1); + expect(resolve).not.toHaveBeenCalled(); + }); + + it("should call drainNow and execute all tasks immediately", async () => { + const exec = jest.fn().mockResolvedValue("drained"); + const resolve = jest.fn(); + const reject = jest.fn(); + const task = createTask({ exec, resolve, reject }); + + queue.enqueue(task); + queue.drainNow(); + + await Promise.resolve(); + expect(exec).toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledWith("drained"); + expect(reject).not.toHaveBeenCalled(); + }); + + it("should set lastRunAt when task is executed", async () => { + const exec = jest.fn().mockResolvedValue("ok"); + const resolve = jest.fn(); + const reject = jest.fn(); + const task = createTask({ exec, resolve, reject }); + + queue.enqueue(task); + jest.runOnlyPendingTimers(); + await Promise.resolve(); + + expect(typeof task.lastRunAt).toBe("number"); + expect(task.lastRunAt).toBeLessThanOrEqual(Date.now()); + }); + + function createTask(overrides: Partial = {}): RetryTask { + return { + id: "id1", + tryNo: 0, + createdAt: Date.now(), + exec: jest.fn().mockResolvedValue(undefined), + resolve: jest.fn(), + reject: jest.fn(), + ...overrides, + }; + } +}); diff --git a/Tokenization/backend/wrapper/src/utils/connection/peerListener.ts b/Tokenization/backend/wrapper/src/utils/connection/peerListener.ts index 5fafb4f1d..b65d80e15 100644 --- a/Tokenization/backend/wrapper/src/utils/connection/peerListener.ts +++ b/Tokenization/backend/wrapper/src/utils/connection/peerListener.ts @@ -12,9 +12,11 @@ * or submit itself to any jurisdiction. */ import * as grpc from '@grpc/grpc-js'; -import { Connection } from '../../client/connection/Connection'; -import { ConnectionDirection } from '../../models/message.model'; +import type { ConnectionDirection } from '../../models/message.model'; import { ConnectionStatus } from '../../models/connection.model'; +import type { Connection } from '../../client/connection/Connection'; +import { GRPCAuthInterceptor } from '../../client/connectionManager/interceptors/grpc.auth.interceptor'; +import type { SecurityContext } from '../security/SecurityContext'; /** * Listens for incoming gRPC requests and forwards them to the local API endpoint. @@ -24,7 +26,7 @@ import { ConnectionStatus } from '../../models/connection.model'; * @param callback - The callback function to be called with the response. * @param logger - The logger object to write info and error messages. * @param receivingConnections - The map of existing incoming connections. - * @param peerCtor - The constructor function for the peer client. + * @param createNewConnection - Function to create a new Connection instance. * @param baseAPIPath - The base path of the local API endpoint. */ export const peerListener = async ( @@ -32,21 +34,35 @@ export const peerListener = async ( callback: grpc.sendUnaryData, logger: any, receivingConnections: Map, - peerCtor: any, + createNewConnection: (address: string, direction: ConnectionDirection, token?: string | undefined) => Promise, + securityContext: SecurityContext, baseAPIPath: string ) => { + // Create a minimal ConnectionManager interface for the interceptor + const connectionManagerInterface = { + getConnectionByAddress: (address: string, _direction: ConnectionDirection) => receivingConnections.get(address), + createNewConnection: createNewConnection, + sendCentralAlert: () => { + // Peer listener doesn't have access to central connection, so we just log + logger.warnMessage('Alert would be sent to central system if connection was available'); + }, + }; + + // Run auth interceptor + const interceptor = new GRPCAuthInterceptor(connectionManagerInterface as any, securityContext); + const { isAuthenticated, conn } = await interceptor.validate(call, callback); + + if (!isAuthenticated || !conn) { + // Authentication failed - response already sent in interceptor + return; + } + try { const clientAddress = call.getPeer(); logger.infoMessage(`Incoming request from ${clientAddress}`); - let conn: Connection | undefined = receivingConnections.get(clientAddress); - - if (!conn) { - conn = new Connection('', clientAddress, ConnectionDirection.RECEIVING, peerCtor); - conn.status = ConnectionStatus.CONNECTED; - receivingConnections.set(clientAddress, conn); - logger.infoMessage(`New incoming connection registered for: ${clientAddress}`); - } + conn.status = ConnectionStatus.CONNECTED; + receivingConnections.set(clientAddress, conn); // Create request to forward to local API endpoint const method = String(call.request?.method ?? 'POST').toUpperCase(); diff --git a/Tokenization/backend/wrapper/src/utils/connection/reconnectionScheduler.ts b/Tokenization/backend/wrapper/src/utils/connection/reconnectionScheduler.ts index 4b03d434d..ef0c5699d 100644 --- a/Tokenization/backend/wrapper/src/utils/connection/reconnectionScheduler.ts +++ b/Tokenization/backend/wrapper/src/utils/connection/reconnectionScheduler.ts @@ -17,6 +17,16 @@ export interface ReconnectionOptions { maxDelay?: number; // Maximum delay in ms } +/** + * Logger interface matching @aliceo2/web-ui LogManager logger + */ +interface Logger { + infoMessage(message: string, ...args: any[]): void; + errorMessage(message: string, ...args: any[]): void; + warnMessage(message: string, ...args: any[]): void; + debugMessage(message: string, ...args: any[]): void; +} + /** * A scheduler that manages reconnection attempts with an exponential backoff. */ @@ -29,7 +39,6 @@ export class ReconnectionScheduler { private timeoutId: any; private logger: Logger; - private isResetting: boolean = false; private isScheduling: boolean = false; /** @@ -56,7 +65,6 @@ export class ReconnectionScheduler { schedule() { if (this.isScheduling) return; this.isScheduling = true; - this.isResetting = false; this.attemptCount++; // Exponential backoff calculation @@ -64,7 +72,7 @@ export class ReconnectionScheduler { this.currentDelay = Math.min(this.maxDelay, delay); - this.logger.infoMessage(`Recconection attempt #${this.attemptCount}: Sleep for ${this.currentDelay.toFixed(0)} ms.`); + this.logger.infoMessage(`Reconnection attempt #${this.attemptCount}: Sleep for ${this.currentDelay.toFixed(0)} ms.`); // Plan the reconnection attempt this.timeoutId = setTimeout(() => { @@ -77,10 +85,7 @@ export class ReconnectionScheduler { * Resets the scheduler to its initial state. */ reset() { - if (this.isResetting) return; this.isScheduling = false; - this.isResetting = true; - clearTimeout(this.timeoutId); this.attemptCount = 0; this.currentDelay = this.initialDelay; diff --git a/Tokenization/backend/wrapper/src/utils/custom.identifier.ts b/Tokenization/backend/wrapper/src/utils/custom.identifier.ts new file mode 100644 index 000000000..870869b79 --- /dev/null +++ b/Tokenization/backend/wrapper/src/utils/custom.identifier.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * Generates a unique identifier string composed of the current timestamp + * and a random value. The timestamp is represented as a base-36 string + * and the random value is a base-36 string of four random 32-bit integers. + * The two values are separated by a hyphen. + * @returns A unique identifier string. + */ +export const genId = (): string => { + const time = Date.now().toString(36); + const rand = Array.from(crypto.getRandomValues(new Uint32Array(4))) + .map((x) => x.toString(36)) + .join(""); + return `${time}-${rand}`; +}; diff --git a/Tokenization/backend/wrapper/src/utils/queues/RetryQueue.ts b/Tokenization/backend/wrapper/src/utils/queues/RetryQueue.ts new file mode 100644 index 000000000..a704295c6 --- /dev/null +++ b/Tokenization/backend/wrapper/src/utils/queues/RetryQueue.ts @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +export type RetryTaskFn = () => Promise; + +export interface RetryTask { + id: string; + tryNo: number; + createdAt: number; + lastRunAt?: number; + reason?: string; + exec: RetryTaskFn; + resolve: (v: T) => void; + reject: (e: any) => void; +} + +export interface RetryQueueOptions { + maxRetries?: number; // default 5 + baseDelayMs?: number; // default 300 + maxDelayMs?: number; // default 8000 + jitter?: boolean; // default true +} + +export class RetryQueue { + private q: RetryTask[] = []; + private running = false; + private opts: Required; + + /** + * @description Creates a new RetryQueue instance. + * @param {RetryQueueOptions} [opts] - Optional configuration options + * @property {number} [opts.maxRetries] - Max number of retries (default 5) + * @property {number} [opts.baseDelayMs] - Base delay in ms (default 300) + * @property {number} [opts.maxDelayMs] - Max delay in ms (default 8000) + * @property {boolean} [opts.jitter] - Whether to use jitter in delay calculation (default true) + */ + constructor(opts?: RetryQueueOptions) { + this.opts = { + maxRetries: opts?.maxRetries ?? 5, + baseDelayMs: opts?.baseDelayMs ?? 300, + maxDelayMs: opts?.maxDelayMs ?? 8000, + jitter: opts?.jitter ?? true, + }; + } + + /** + * @description Returns the number of tasks currently in the retry queue. + * @returns The number of tasks in the retry queue. + */ + public size(): number { + return this.q.length; + } + + /** + * @description Adds a task to the retry queue. The task will be executed after a delay. + * @param {RetryTask} task - The task to be added to the retry queue. + * @template T - The type of the data returned by the task. + */ + public enqueue(task: RetryTask) { + this.q.push(task); + // we don't start the task – exec will be called after delay or drainNow + this.schedule(task); + } + + /** + * Drains the retry queue by executing all the tasks currently in the queue immediately. + * + * @remarks + * This method is particularly useful when a condition changes that makes all pending + * tasks likely to succeed (e.g., a new authentication token is received). It: + * + * 1. **Prevents concurrent draining**: Returns early if already running to avoid race conditions + * 2. **Clears the queue atomically**: Takes a snapshot of all tasks and clears the queue + * 3. **Executes all tasks immediately**: Bypasses the exponential backoff delay + * 4. **Handles failures gracefully**: Failed tasks are re-enqueued with incremented retry count + * 5. **Respects retry limits**: Tasks exceeding maxRetries are rejected + * + * @example + * ```typescript + * // When a new token is received, drain all pending requests + * connection.handleNewToken(newToken); + * retryQueue.drainNow(); // All queued requests will retry immediately + * ``` + */ + public drainNow() { + if (this.running) return; + this.running = true; + const tasks = [...this.q]; + this.q = []; + for (const t of tasks) { + t.exec() + .then(t.resolve) + .catch((e) => { + // if failed, re-enqueue with incremented tryNo + t.tryNo++; + if (t.tryNo > this.opts.maxRetries) { + t.reject(e); + return; + } + this.enqueue(t); + }); + } + this.running = false; + } + + /** + * Schedules a task to be executed after an exponential backoff delay. + * + * @remarks + * This method implements a sophisticated retry strategy with the following features: + * + * **Exponential Backoff Calculation:** + * - Base formula: `delay = baseDelayMs * 2^tryNo` + * - Capped at maxDelayMs to prevent excessive wait times + * - Example with baseDelayMs=300ms: 300ms → 600ms → 1200ms → 2400ms → 4800ms + * + * **Jitter (Optional):** + * - Adds randomness to prevent thundering herd problem + * - Random multiplier between 0.5 and 1.0 + * - Helps distribute retry attempts across time + * + * **Automatic Re-scheduling:** + * - Failed tasks are automatically re-enqueued with incremented tryNo + * - Tasks exceeding maxRetries are rejected with the last error + * - Successful tasks resolve their original promise + * + * @param task - The task to be scheduled with retry metadata + * + * @example + * ```typescript + * // Task will be retried with delays: 300ms, 600ms, 1200ms, 2400ms, 4800ms + * const queue = new RetryQueue({ baseDelayMs: 300, maxRetries: 5 }); + * ``` + */ + private schedule(task: RetryTask) { + const { baseDelayMs, maxDelayMs, jitter } = this.opts; + const exp = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, task.tryNo)); + const delay = jitter ? Math.floor(exp * (0.5 + Math.random())) : exp; + + setTimeout(() => { + task.lastRunAt = Date.now(); + task + .exec() + .then(task.resolve) + .catch((e) => { + task.tryNo++; + if (task.tryNo > this.opts.maxRetries) { + task.reject(e); + return; + } + this.enqueue(task); + }); + }, delay); + } +} diff --git a/Tokenization/backend/wrapper/src/utils/security/SecurityContext.ts b/Tokenization/backend/wrapper/src/utils/security/SecurityContext.ts new file mode 100644 index 000000000..88685a30c --- /dev/null +++ b/Tokenization/backend/wrapper/src/utils/security/SecurityContext.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * @description Stores every keys and certificates needed for gRPC mTLS communication and token verifications (JWE/JWS) + */ +export class SecurityContext { + // Keys for mTLS (RSA) + public readonly caCert: Buffer; + public readonly clientSenderCert: Buffer; + public readonly clientListenerCert?: Buffer; + public readonly clientPublicKey: Buffer; + // RSA Private Key (PKCS8) for JWE decryption + public readonly clientPrivateKey: Buffer; + + // Public Ed25519 key for JWS verification + public readonly JWS_PUBLIC_KEY: string; + + /** + * Initializes an instance of SecurityContext class. + * + * @param caCert - The root Certificate Authority (CA) certificate used for mTLS. + * @param clientSenderCert - The client certificate used for mTLS. + * @param clientPrivateKey - The client private key (PKCS8) used for JWE decryption. + * @param clientPublicKey - The client public key used for JWE encryption. + * @param clientListenerCert - The client listener certificate (optional) used for mTLS. + * @param JWS_PUBLIC_KEY - The public Ed25519 key used for JWS verification (optional, default value is provided if not set). + */ + constructor( + caCert: Buffer, + clientSenderCert: Buffer, + clientPrivateKey: Buffer, + clientPublicKey: Buffer, + clientListenerCert?: Buffer, + JWS_PUBLIC_KEY?: string + ) { + this.caCert = caCert; + this.clientSenderCert = clientSenderCert; + this.clientPrivateKey = clientPrivateKey; + this.clientPublicKey = clientPublicKey; + + if (clientListenerCert) { + this.clientListenerCert = clientListenerCert; + } + + if (JWS_PUBLIC_KEY) { + this.JWS_PUBLIC_KEY = JWS_PUBLIC_KEY; + } else { + this.JWS_PUBLIC_KEY = 'hTb3l5gwoIWISOLi6cQMwcultawKyA6vxnimXWtE6JI='; + } + } +}